diff --git a/go.mod b/go.mod index b4cc27ddfd..499d5f2cc8 100644 --- a/go.mod +++ b/go.mod @@ -25,7 +25,6 @@ require ( github.com/consensys/gnark-crypto v0.18.1 // indirect github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect github.com/crewjam/httperr v0.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect github.com/fatih/color v1.16.0 // indirect @@ -77,7 +76,6 @@ require ( github.com/microcosm-cc/bluemonday v1.0.26 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/philhofer/fwd v1.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect @@ -88,7 +86,7 @@ require ( github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect github.com/spf13/pflag v1.0.6 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/x448/float16 v0.8.4 // indirect @@ -96,6 +94,7 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/mod v0.40.0 // indirect golang.org/x/net v0.57.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect @@ -104,7 +103,6 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( @@ -130,7 +128,7 @@ require ( github.com/go-webauthn/webauthn v0.16.5 github.com/gobuffalo/pop/v6 v6.1.1 github.com/gobwas/glob v0.2.3 - github.com/gofrs/uuid v4.3.1+incompatible + github.com/gofrs/uuid v4.4.0+incompatible github.com/golang-jwt/jwt/v5 v5.3.1 github.com/jackc/pgconn v1.14.3 github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451 @@ -150,7 +148,8 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.1 github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20240303152453-e0e82adf1721 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.1 + github.com/supabase-community/scim-go v0.1.0 github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869 github.com/xeipuuv/gojsonschema v1.2.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 diff --git a/go.sum b/go.sum index f7432c9c01..8ffeb52909 100644 --- a/go.sum +++ b/go.sum @@ -184,8 +184,9 @@ github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -363,7 +364,6 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= @@ -424,8 +424,8 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -435,8 +435,10 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/supabase-community/scim-go v0.1.0 h1:gAWzYP2cewTTrp4c0nfPlcLFH4X0eK5hJPFlO5OaGnA= +github.com/supabase-community/scim-go v0.1.0/go.mod h1:3UXuoz0bce+BQlY0VvMnAjOqcfssWgyj4iTheoXMpG8= github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869 h1:VDuRtwen5Z7QQ5ctuHUse4wAv/JozkKZkdic5vUV4Lg= github.com/supabase/hibp v0.0.0-20231124125943-d225752ae869/go.mod h1:eHX5nlSMSnyPjUrbYzeqrA8snCe2SKyfizKjU3dkfOw= github.com/supranational/blst v0.3.16-0.20250831170142-f48500c1fdbe h1:nbdqkIGOGfUAD54q1s2YBcBz/WcsxCO9HUQ4aGV5hUw= @@ -507,6 +509,8 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= diff --git a/internal/api/api.go b/internal/api/api.go index 96f6f6d015..c32dbf63d2 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -138,7 +138,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne api.oauthServer = oauthserver.NewServer(globalConfig, db, api.tokenService) } - api.scim = scim.NewServer(globalConfig) + api.scim = scim.NewServer(db, globalConfig.API.ExternalURL) if api.config.Password.HIBP.Enabled { httpClient := &http.Client{ @@ -467,7 +467,16 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.Get("/ServiceProviderConfig", api.scim.ServiceProviderConfig) r.Get("/ResourceTypes", api.scim.ResourceTypes) + r.Get("/ResourceTypes/{id}", api.scim.ResourceTypeByID) r.Get("/Schemas", api.scim.Schemas) + r.Get("/Schemas/{id}", api.scim.SchemaByID) + + tenant := r.WithBypass(api.scim.Tenant) + tenant.Get("/Users", api.scim.Users) + tenant.Post("/Users", api.scim.CreateUser) + tenant.Get("/Users/{id}", api.scim.UserByID) + tenant.Put("/Users/{id}", api.scim.ReplaceUser) + tenant.Delete("/Users/{id}", api.scim.DeleteUser) }) }) diff --git a/internal/api/scim/core/core.go b/internal/api/scim/core/core.go deleted file mode 100644 index d625dab4e1..0000000000 --- a/internal/api/scim/core/core.go +++ /dev/null @@ -1,8 +0,0 @@ -// Package core implements the SCIM 2.0 core schema defined in RFC 7643. -package core - -// SchemaURI identifies a SCIM schema -type SchemaURI string - -// ResourceTypeName names a resource type -type ResourceTypeName string diff --git a/internal/api/scim/core/endpoints.go b/internal/api/scim/core/endpoints.go deleted file mode 100644 index b1f9003dfb..0000000000 --- a/internal/api/scim/core/endpoints.go +++ /dev/null @@ -1,6 +0,0 @@ -package core - -// The resource endpoints of RFC 7644, Section 3.2, relative to the base URL -const ( - EndpointServiceProviderConfig = "/ServiceProviderConfig" -) diff --git a/internal/api/scim/core/meta.go b/internal/api/scim/core/meta.go deleted file mode 100644 index a47e4a4b30..0000000000 --- a/internal/api/scim/core/meta.go +++ /dev/null @@ -1,14 +0,0 @@ -package core - -// Meta is the resource metadata common attribute defined in RFC 7643, Section 3.1. -type Meta struct { - ResourceType ResourceTypeName `json:"resourceType"` - Location string `json:"location,omitempty"` -} - -func NewMeta(baseURL string, resourceType ResourceTypeName, endpoint string) Meta { - return Meta{ - ResourceType: resourceType, - Location: baseURL + endpoint, - } -} diff --git a/internal/api/scim/core/meta_test.go b/internal/api/scim/core/meta_test.go deleted file mode 100644 index 4b7383bd70..0000000000 --- a/internal/api/scim/core/meta_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package core - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNewMeta(t *testing.T) { - t.Run("locates the resource at its endpoint", func(t *testing.T) { - meta := NewMeta("http://localhost:9999/scim/v2", ResourceTypeServiceProviderConfig, EndpointServiceProviderConfig) - - require.Equal(t, ResourceTypeServiceProviderConfig, meta.ResourceType) - require.Equal(t, "http://localhost:9999/scim/v2/ServiceProviderConfig", meta.Location) - }) -} - -func TestMeta(t *testing.T) { - t.Run("serializes to JSON correctly", func(t *testing.T) { - body, err := json.Marshal(Meta{ - ResourceType: ResourceTypeServiceProviderConfig, - Location: "http://localhost:9999/scim/v2/ServiceProviderConfig", - }) - - require.NoError(t, err) - require.JSONEq(t, `{ - "resourceType": "ServiceProviderConfig", - "location": "http://localhost:9999/scim/v2/ServiceProviderConfig" - }`, string(body)) - }) - - t.Run("omits the location when it is empty", func(t *testing.T) { - body, err := json.Marshal(Meta{ResourceType: ResourceTypeServiceProviderConfig}) - - require.NoError(t, err) - require.JSONEq(t, `{"resourceType": "ServiceProviderConfig"}`, string(body)) - }) -} diff --git a/internal/api/scim/core/schemas.go b/internal/api/scim/core/schemas.go deleted file mode 100644 index 128b2ea719..0000000000 --- a/internal/api/scim/core/schemas.go +++ /dev/null @@ -1,13 +0,0 @@ -package core - -// The schema URIs of RFC 7643 -const ( - schemaRoot = "urn:ietf:params:scim:schemas" - schemaCore = schemaRoot + ":core:2.0" - - SchemaServiceProviderConfig SchemaURI = schemaCore + ":ServiceProviderConfig" -) - -const ( - ResourceTypeServiceProviderConfig ResourceTypeName = "ServiceProviderConfig" -) diff --git a/internal/api/scim/core/service_provider_config.go b/internal/api/scim/core/service_provider_config.go deleted file mode 100644 index 26c64da947..0000000000 --- a/internal/api/scim/core/service_provider_config.go +++ /dev/null @@ -1,70 +0,0 @@ -package core - -type SupportedFeature struct { - Supported bool `json:"supported"` -} - -type BulkFeature struct { - Supported bool `json:"supported"` - MaxOperations int `json:"maxOperations"` - MaxPayloadSize int `json:"maxPayloadSize"` -} - -type FilterFeature struct { - Supported bool `json:"supported"` - MaxResults int `json:"maxResults"` -} - -type AuthenticationSchemeType string - -const ( - AuthenticationSchemeOAuthBearerToken AuthenticationSchemeType = "oauthbearertoken" -) - -// AuthenticationScheme is the authentication scheme of RFC 7643, Section 5. -type AuthenticationScheme struct { - Type AuthenticationSchemeType `json:"type"` - Name string `json:"name"` - Description string `json:"description"` - SpecURI string `json:"specUri,omitempty"` - Primary bool `json:"primary"` -} - -func NewOAuthBearerToken() *AuthenticationScheme { - return &AuthenticationScheme{ - Type: AuthenticationSchemeOAuthBearerToken, - Name: "OAuth Bearer Token", - Description: "Authentication scheme using the OAuth Bearer Token Standard", - SpecURI: "http://www.rfc-editor.org/info/rfc6750", - } -} - -func (scheme *AuthenticationScheme) AsPrimary() *AuthenticationScheme { - scheme.Primary = true - return scheme -} - -// ServiceProviderConfig is the schema defined in RFC 7643, Section 5. -type ServiceProviderConfig struct { - Schemas []SchemaURI `json:"schemas"` - Patch SupportedFeature `json:"patch"` - Bulk BulkFeature `json:"bulk"` - Filter FilterFeature `json:"filter"` - ChangePassword SupportedFeature `json:"changePassword"` - Sort SupportedFeature `json:"sort"` - ETag SupportedFeature `json:"etag"` - AuthenticationSchemes []*AuthenticationScheme `json:"authenticationSchemes"` - Meta Meta `json:"meta"` -} - -func NewServiceProviderConfig(baseURL string, schemes ...*AuthenticationScheme) *ServiceProviderConfig { - if schemes == nil { - schemes = []*AuthenticationScheme{} - } - - return &ServiceProviderConfig{ - Schemas: []SchemaURI{SchemaServiceProviderConfig}, - AuthenticationSchemes: schemes, - Meta: NewMeta(baseURL, ResourceTypeServiceProviderConfig, EndpointServiceProviderConfig), - } -} diff --git a/internal/api/scim/core/service_provider_config_test.go b/internal/api/scim/core/service_provider_config_test.go deleted file mode 100644 index 03ff2dca95..0000000000 --- a/internal/api/scim/core/service_provider_config_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package core - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewServiceProviderConfig(t *testing.T) { - t.Run("advertises the schemes the caller declares", func(t *testing.T) { - scheme := NewOAuthBearerToken().AsPrimary() - - config := NewServiceProviderConfig("", scheme) - - require.Equal(t, []SchemaURI{SchemaServiceProviderConfig}, config.Schemas) - require.Equal(t, []*AuthenticationScheme{scheme}, config.AuthenticationSchemes) - }) - - t.Run("identifies itself with resource metadata", func(t *testing.T) { - baseURL := "http://localhost:9999/scim/v2" - - config := NewServiceProviderConfig(baseURL) - - require.Equal(t, ResourceTypeServiceProviderConfig, config.Meta.ResourceType) - require.Equal(t, baseURL+EndpointServiceProviderConfig, config.Meta.Location) - }) - - t.Run("supports none of the optional protocol features", func(t *testing.T) { - config := NewServiceProviderConfig("") - - assert.False(t, config.Patch.Supported) - assert.False(t, config.Bulk.Supported) - assert.False(t, config.Filter.Supported) - assert.False(t, config.ChangePassword.Supported) - assert.False(t, config.Sort.Supported) - assert.False(t, config.ETag.Supported) - }) - - t.Run("serializes authenticationSchemes as an array", func(t *testing.T) { - body, err := json.Marshal(NewServiceProviderConfig("")) - - require.NoError(t, err) - require.Contains(t, string(body), `"authenticationSchemes":[]`) - }) -} - -func TestAuthenticationScheme(t *testing.T) { - t.Run("NewOAuthBearerToken", func(t *testing.T) { - scheme := NewOAuthBearerToken() - - assert.Equal(t, AuthenticationSchemeOAuthBearerToken, scheme.Type) - assert.Equal(t, "OAuth Bearer Token", scheme.Name) - assert.Equal(t, "Authentication scheme using the OAuth Bearer Token Standard", scheme.Description) - assert.Equal(t, "http://www.rfc-editor.org/info/rfc6750", scheme.SpecURI) - assert.False(t, scheme.Primary) - }) - - t.Run("AsPrimary marks the scheme primary", func(t *testing.T) { - scheme := NewOAuthBearerToken() - - require.Same(t, scheme, scheme.AsPrimary()) - assert.True(t, scheme.Primary) - }) -} diff --git a/internal/api/scim/helpers_test.go b/internal/api/scim/helpers_test.go new file mode 100644 index 0000000000..e6091c0d84 --- /dev/null +++ b/internal/api/scim/helpers_test.go @@ -0,0 +1,79 @@ +package scim + +import ( + "encoding/json" + "testing" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/require" + + "github.com/supabase-community/scim-go/pkg/core" + "github.com/supabase/auth/internal/conf/confload" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" + "github.com/supabase/auth/internal/storage/test" +) + +const scimTestConfig = "../../../hack/test.env" + +const testExternalURL = "http://localhost:9999" + +func newTestDB(t *testing.T) *storage.Connection { + t.Helper() + + globalConfig, err := confload.LoadGlobal(scimTestConfig) + require.NoError(t, err) + + conn, err := test.SetupDBConnection(globalConfig) + require.NoError(t, err) + + return conn +} + +func createTenant(t *testing.T, db *storage.Connection) *Tenant { + t.Helper() + + provider := &Tenant{ID: uuid.Must(uuid.NewV4())} + require.NoError(t, db.Create(provider)) + + t.Cleanup(func() { + _ = db.Destroy(provider) + }) + + return provider +} + +func createUser(t *testing.T, db *storage.Connection, provider *Tenant, user *core.User) { + t.Helper() + + stored := *user + stored.ID, stored.Meta = "", core.Meta{} + + document, err := json.Marshal(&stored) + require.NoError(t, err) + + require.NoError(t, db.RawQuery( + "INSERT INTO scim_users (id, sso_provider_id, resource, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + user.ID, + provider.ID, + string(document), + user.Meta.Created, + user.Meta.LastModified, + ).Exec()) +} + +func createToken(t *testing.T, db *storage.Connection, provider *Tenant) (*models.SCIMToken, string) { + t.Helper() + + token, raw := models.NewSCIMToken(provider) + require.NoError(t, db.Create(token)) + return token, raw +} + +func userNamesOf(users []*core.User) []string { + names := make([]string, 0, len(users)) + for _, user := range users { + names = append(names, user.UserName) + } + return names +} diff --git a/internal/api/scim/protocol/error.go b/internal/api/scim/protocol/error.go deleted file mode 100644 index fb183692f1..0000000000 --- a/internal/api/scim/protocol/error.go +++ /dev/null @@ -1,24 +0,0 @@ -package protocol - -import ( - "strconv" -) - -const SchemaError = "urn:ietf:params:scim:api:messages:2.0:Error" - -// Error is the error message form defined in RFC 7644, Section 3.12. -type Error struct { - Schemas []string `json:"schemas"` - ScimType string `json:"scimType,omitempty"` - Detail string `json:"detail,omitempty"` - Status string `json:"status"` -} - -func NewError(status int, scimType string, detail string) *Error { - return &Error{ - Schemas: []string{SchemaError}, - ScimType: scimType, - Detail: detail, - Status: strconv.Itoa(status), - } -} diff --git a/internal/api/scim/protocol/error_test.go b/internal/api/scim/protocol/error_test.go deleted file mode 100644 index aede262a84..0000000000 --- a/internal/api/scim/protocol/error_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package protocol - -import ( - "encoding/json" - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewError(t *testing.T) { - t.Run("serializes to JSON correctly", func(t *testing.T) { - body, err := json.Marshal(NewError(http.StatusNotFound, "", "Endpoint or resource does not exist")) - - require.NoError(t, err) - assert.JSONEq(t, `{ - "schemas": [ - "urn:ietf:params:scim:api:messages:2.0:Error" - ], - "status": "404", - "detail": "Endpoint or resource does not exist" - }`, string(body)) - }) - - t.Run("includes the scimType when one is given", func(t *testing.T) { - body, err := json.Marshal(NewError(http.StatusBadRequest, "invalidValue", "A required value was missing")) - - require.NoError(t, err) - assert.JSONEq(t, `{ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "scimType": "invalidValue", - "detail": "A required value was missing", - "status": "400" - }`, string(body)) - }) - - t.Run("omits the optional attributes when they are empty", func(t *testing.T) { - body, err := json.Marshal(NewError(http.StatusBadRequest, "", "")) - - require.NoError(t, err) - assert.JSONEq(t, `{ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status": "400" - }`, string(body)) - }) -} diff --git a/internal/api/scim/protocol/list_response.go b/internal/api/scim/protocol/list_response.go deleted file mode 100644 index 972229f71f..0000000000 --- a/internal/api/scim/protocol/list_response.go +++ /dev/null @@ -1,25 +0,0 @@ -package protocol - -const SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" - -type ListResponse[T any] struct { - Schemas []string `json:"schemas"` - TotalResults int `json:"totalResults"` - StartIndex int `json:"startIndex"` - ItemsPerPage int `json:"itemsPerPage"` - Resources []T `json:"Resources"` -} - -func NewListResponse[T any](resources []T) *ListResponse[T] { - if resources == nil { - resources = []T{} - } - n := len(resources) - return &ListResponse[T]{ - Schemas: []string{SchemaListResponse}, - TotalResults: n, - StartIndex: 1, - ItemsPerPage: n, - Resources: resources, - } -} diff --git a/internal/api/scim/protocol/list_response_test.go b/internal/api/scim/protocol/list_response_test.go deleted file mode 100644 index 6c4de87bdd..0000000000 --- a/internal/api/scim/protocol/list_response_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package protocol - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/require" -) - -const emptyListResponse = `{ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "totalResults": 0, - "startIndex": 1, - "itemsPerPage": 0, - "Resources": [] -}` - -func TestNewListResponse(t *testing.T) { - for _, tc := range []struct { - name string - resources []string - expected string - }{ - { - name: "nil resources marshal to an empty array", - resources: nil, - expected: emptyListResponse, - }, - { - name: "empty resources marshal to an empty array", - resources: []string{}, - expected: emptyListResponse, - }, - { - name: "populated resources are counted", - resources: []string{"a", "b"}, - expected: `{ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "totalResults": 2, - "startIndex": 1, - "itemsPerPage": 2, - "Resources": ["a", "b"] - }`, - }, - } { - t.Run(tc.name, func(t *testing.T) { - body, err := json.Marshal(NewListResponse(tc.resources)) - - require.NoError(t, err) - require.JSONEq(t, tc.expected, string(body)) - }) - } -} diff --git a/internal/api/scim/protocol/protocol.go b/internal/api/scim/protocol/protocol.go deleted file mode 100644 index 7e3f4fbffb..0000000000 --- a/internal/api/scim/protocol/protocol.go +++ /dev/null @@ -1,18 +0,0 @@ -// Package protocol implements the SCIM 2.0 protocol defined in RFC 7644. -package protocol - -import ( - "net/http" - - "github.com/supabase/auth/internal/api/shared" -) - -const MediaType = "application/scim+json" - -func Send(w http.ResponseWriter, status int, obj any) error { - return shared.JSON(w).ContentType(MediaType).Status(status).Send(obj) -} - -func SendError(w http.ResponseWriter, status int, scimType string, detail string) error { - return Send(w, status, NewError(status, scimType, detail)) -} diff --git a/internal/api/scim/protocol/protocol_test.go b/internal/api/scim/protocol/protocol_test.go deleted file mode 100644 index a23ec040ef..0000000000 --- a/internal/api/scim/protocol/protocol_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package protocol - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestSend(t *testing.T) { - t.Run("writes a JSON response with a SCIM media type", func(t *testing.T) { - w := httptest.NewRecorder() - - err := Send(w, http.StatusTeapot, map[string]string{"key": "value"}) - require.NoError(t, err) - - assert.Equal(t, http.StatusTeapot, w.Code) - assert.Equal(t, "application/scim+json", w.Header().Get("Content-Type")) - assert.JSONEq(t, `{"key":"value"}`, w.Body.String()) - }) -} diff --git a/internal/api/scim/repository.go b/internal/api/scim/repository.go new file mode 100644 index 0000000000..bcfc059a06 --- /dev/null +++ b/internal/api/scim/repository.go @@ -0,0 +1,15 @@ +package scim + +import ( + "context" + + "github.com/supabase-community/scim-go/pkg/protocol" +) + +type Repository[T any] interface { + Get(ctx context.Context, id string) (T, error) + List(ctx context.Context, query *protocol.SearchRequest) (items []T, total int, err error) + Create(ctx context.Context, item T) (T, error) + Replace(ctx context.Context, id string, item T) (T, error) + Delete(ctx context.Context, id string) error +} diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go index 4de38e4ce3..4cb60321f7 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -1,48 +1,362 @@ package scim import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" "net/http" + "net/url" + "slices" "strings" - "github.com/supabase/auth/internal/api/scim/core" - "github.com/supabase/auth/internal/api/scim/protocol" - "github.com/supabase/auth/internal/conf" + "github.com/go-chi/chi/v5" + "github.com/gofrs/uuid" + "github.com/supabase-community/scim-go/pkg/core" + "github.com/supabase-community/scim-go/pkg/protocol" + "github.com/supabase/auth/internal/api/shared" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/observability" + "github.com/supabase/auth/internal/storage" ) const BasePath = "/scim/v2" type Server struct { + db *storage.Connection + limits protocol.Limits + users Repository[*core.User] serviceProviderConfig *core.ServiceProviderConfig + resourceTypes []*core.ResourceType + schemas []*core.Schema } -func NewServer(config *conf.GlobalConfiguration) *Server { +func NewServer(db *storage.Connection, externalURL string) *Server { + baseURL := Join(externalURL, BasePath) + userSchema := newUserSchema(baseURL) + return &Server{ - serviceProviderConfig: core.NewServiceProviderConfig( - strings.TrimRight(config.API.ExternalURL, "/")+BasePath, + db: db, + limits: protocol.DefaultLimits, + users: &userRepository{db: db, baseURL: baseURL}, + serviceProviderConfig: newServiceProviderConfig( + baseURL, core.NewOAuthBearerToken().AsPrimary(), - ), + ).Sorting(), + resourceTypes: []*core.ResourceType{newUserResourceType(baseURL, userSchema)}, + schemas: []*core.Schema{userSchema}, + } +} + +func Join(base, segment string) string { + return strings.TrimSuffix(base, "/") + "/" + strings.TrimPrefix(segment, "/") +} + +func newServiceProviderConfig(baseURL string, schemes ...*core.AuthenticationScheme) *core.ServiceProviderConfig { + if schemes == nil { + schemes = []*core.AuthenticationScheme{} + } + return &core.ServiceProviderConfig{ + Schemas: []core.SchemaURI{core.SchemaServiceProviderConfig}, + AuthenticationSchemes: schemes, + Meta: core.Meta{ + ResourceType: "ServiceProviderConfig", + Location: Join(baseURL, "/ServiceProviderConfig"), + }, } } +func newUserResourceType(baseURL string, schema *core.Schema) *core.ResourceType { + return &core.ResourceType{ + Schemas: []core.SchemaURI{core.SchemaResourceType}, + ID: "User", + Name: "User", + Description: schema.Description, + Endpoint: "/Users", + Schema: schema.ID, + Meta: core.Meta{ + ResourceType: "ResourceType", + Location: Join(Join(baseURL, "/ResourceTypes"), "User"), + }, + } +} + +func (srv *Server) Tenant(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, ok := srv.tenant(w, r) + if !ok { + return + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + func (srv *Server) ServiceProviderConfig(w http.ResponseWriter, r *http.Request) error { return protocol.Send(w, http.StatusOK, srv.serviceProviderConfig) } func (srv *Server) ResourceTypes(w http.ResponseWriter, r *http.Request) error { - return list(w, r, []any{}) + return srv.list(w, r, srv.resourceTypes) +} + +func (srv *Server) ResourceTypeByID(w http.ResponseWriter, r *http.Request) error { + return srv.byID(w, r, srv.resourceTypes) } func (srv *Server) Schemas(w http.ResponseWriter, r *http.Request) error { - return list(w, r, []any{}) + return srv.list(w, r, srv.schemas) +} + +func (srv *Server) SchemaByID(w http.ResponseWriter, r *http.Request) error { + return srv.byID(w, r, srv.schemas) +} + +func (srv *Server) Users(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + query, err := srv.limits.ParseSearchRequest(r.URL.Query()) + if err != nil { + return protocol.SendError(w, err) + } + + items, total, err := srv.users.List(ctx, query) + if err != nil { + return srv.sendError(w, r, err) + } + + return protocol.Send(w, http.StatusOK, protocol.NewListResponse(query.StartIndex, total, items)) +} + +func (srv *Server) UserByID(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + id, err := uuid.FromString(urlParam(r, "id")) + if err != nil { + return srv.NotFound(w, r) + } + + user, err := srv.users.Get(ctx, id.String()) + if err != nil { + if errors.Is(err, ErrNotFound) { + return srv.NotFound(w, r) + } + return srv.sendError(w, r, err) + } + + return protocol.Send(w, http.StatusOK, user) +} + +func (srv *Server) CreateUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + user, err := srv.decodeUser(r) + if err != nil { + return protocol.SendError(w, err) + } + if err := srv.validateUser(user); err != nil { + return protocol.SendError(w, err) + } + + created, err := srv.users.Create(ctx, user) + if err != nil { + return srv.sendError(w, r, err) + } + + w.Header().Set("Location", created.Meta.Location) + return protocol.Send(w, http.StatusCreated, created) +} + +func (srv *Server) ReplaceUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + id, err := uuid.FromString(urlParam(r, "id")) + if err != nil { + return srv.NotFound(w, r) + } + + user, err := srv.decodeUser(r) + if err != nil { + return protocol.SendError(w, err) + } + if err := srv.validateUser(user); err != nil { + return protocol.SendError(w, err) + } + + replaced, err := srv.users.Replace(ctx, id.String(), user) + if err != nil { + if errors.Is(err, ErrNotFound) { + return srv.NotFound(w, r) + } + return srv.sendError(w, r, err) + } + return protocol.Send(w, http.StatusOK, replaced) +} + +func (srv *Server) DeleteUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + id, err := uuid.FromString(urlParam(r, "id")) + if err != nil { + return srv.NotFound(w, r) + } + + if err := srv.users.Delete(ctx, id.String()); err != nil { + if errors.Is(err, ErrNotFound) { + return srv.NotFound(w, r) + } + return srv.sendError(w, r, err) + } + return protocol.Send(w, http.StatusNoContent, nil) } func (srv *Server) NotFound(w http.ResponseWriter, r *http.Request) error { - return protocol.SendError(w, http.StatusNotFound, "", "Endpoint or resource does not exist") + return protocol.SendError(w, protocol.ErrNotFound("Endpoint or resource does not exist")) +} + +func (srv *Server) validateUser(user *core.User) *protocol.Error { + if user.UserName == "" { + return protocol.ErrInvalidValue(`"userName" is required`) + } + if !slices.Contains(user.Schemas, core.SchemaUser) { + return protocol.ErrInvalidValue(`"schemas" must include the User schema URN`) + } + return nil +} + +func (srv *Server) decodeUser(r *http.Request) (*core.User, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + return nil, protocol.ErrTooLarge("the request body is too large") + } + return nil, protocol.ErrInvalidSyntax("could not read the request body") + } + + user := new(core.User) + if err := json.Unmarshal(body, user); err != nil { + return nil, protocol.ErrInvalidSyntax("request body is not a valid User") + } + return user, nil +} + +func (srv *Server) list[T any](w http.ResponseWriter, r *http.Request, resources []T) error { + if rejected, err := srv.rejectFilter(w, r, protocol.ErrForbidden("Filtering is not supported on this endpoint")); rejected { + return err + } + + return protocol.Send(w, http.StatusOK, protocol.NewListResponse(1, len(resources), resources)) +} + +func (srv *Server) byID[T core.Resource](w http.ResponseWriter, r *http.Request, resources []T) error { + id := urlParam(r, "id") + + for _, resource := range resources { + if resource.ResourceID() == id { + return protocol.Send(w, http.StatusOK, resource) + } + } + return srv.NotFound(w, r) +} + +func (srv *Server) sendError(w http.ResponseWriter, r *http.Request, err error) error { + if scimErr, ok := errors.AsType[*protocol.Error](err); ok { + return protocol.SendError(w, scimErr) + } + return srv.internalError(w, r, err) +} + +func (srv *Server) internalError(w http.ResponseWriter, r *http.Request, err error) error { + observability.LogEntrySetField(r, "error", err.Error()) + return protocol.SendError(w, protocol.ErrInternal("Internal server error")) } -func list[T any](w http.ResponseWriter, r *http.Request, resources []T) error { - if r.URL.Query().Has("filter") { - return protocol.SendError(w, http.StatusForbidden, "", "Filtering is not supported on this endpoint") +func (srv *Server) unauthorized(w http.ResponseWriter) error { + w.Header().Set("WWW-Authenticate", `Bearer realm="SCIM"`) + return protocol.SendError(w, protocol.ErrUnauthorized("Bearer token is missing or invalid")) +} + +func (srv *Server) rejectFilter(w http.ResponseWriter, r *http.Request, unsupported *protocol.Error) (bool, error) { + if !r.URL.Query().Has("filter") { + return false, nil + } + return true, protocol.SendError(w, unsupported) +} + +func (srv *Server) tenant(w http.ResponseWriter, r *http.Request) (context.Context, bool) { + ctx := r.Context() + + tenant, err := srv.lookup(ctx, shared.Credential(r)) + if err != nil { + if errors.Is(err, ErrNotFound) { + _ = srv.unauthorized(w) + } else { + _ = srv.internalError(w, r, err) + } + return nil, false + } + + return tenantKey.WithValue(ctx, tenant), true +} + +func (srv *Server) lookup(ctx context.Context, bearerToken string) (*Tenant, error) { + if !strings.HasPrefix(bearerToken, models.SCIMTokenPrefix) { + return nil, ErrNotFound + } + + provider, err := models.FindSSOProviderBySCIMToken(srv.db.WithContext(ctx), bearerToken) + if err != nil { + if errors.Is(err, models.SSOProviderNotFoundError{}) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("scim: looking up token: %w", err) + } + + return provider, nil +} + +func newUserSchema(baseURL string) *core.Schema { + schema := &core.Schema{ + Schemas: []core.SchemaURI{core.SchemaSchema}, + ID: core.SchemaUser, + Name: "User", + Meta: core.Meta{ + ResourceType: "Schema", + Location: Join(Join(baseURL, "/Schemas"), string(core.SchemaUser)), + }, + } + + return schema. + Describe("User Account"). + With( + core.NewAttribute("userName", core.TypeString, "Unique identifier for the User"). + AsRequired(). + UniqueOn(core.UniquenessServer), + + core.NewAttribute("name", core.TypeComplex, "The components of the user's name."). + With( + core.NewAttribute("formatted", core.TypeString, "The name formatted for display."), + core.NewAttribute("familyName", core.TypeString, "The family name of the User."), + core.NewAttribute("givenName", core.TypeString, "The given name of the User."), + core.NewAttribute("middleName", core.TypeString, "The middle name(s) of the User."), + ), + + core.NewAttribute("emails", core.TypeComplex, "Email addresses for the user."). + AsMultiValued(). + With( + core.NewAttribute("value", core.TypeString, "An email address for the user."), + core.NewAttribute("primary", core.TypeBoolean, "The 'primary' email address"), + ), + + core.NewAttribute("active", core.TypeBoolean, ""), + ) +} + +func urlParam(r *http.Request, key string) string { + value := chi.URLParam(r, key) + + if decoded, err := url.PathUnescape(value); err == nil { + return decoded } - return protocol.Send(w, http.StatusOK, protocol.NewListResponse(resources)) + return value } diff --git a/internal/api/scim/server_test.go b/internal/api/scim/server_test.go index 773638bcdd..a1b5844dc5 100644 --- a/internal/api/scim/server_test.go +++ b/internal/api/scim/server_test.go @@ -1,15 +1,25 @@ package scim import ( + "context" "embed" + "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" + "strings" "testing" + "time" + "github.com/go-chi/chi/v5" + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/supabase/auth/internal/api/scim/protocol" - "github.com/supabase/auth/internal/conf" + "github.com/supabase-community/scim-go/pkg/core" + "github.com/supabase-community/scim-go/pkg/protocol" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" ) //go:embed testdata/* @@ -21,24 +31,80 @@ func testFixture(t *testing.T, file string) string { return string(data) } -func newServerFor(externalURL string) *Server { - return NewServer(&conf.GlobalConfiguration{ - API: conf.APIConfiguration{ExternalURL: externalURL}, +func TestServer(t *testing.T) { + db := newTestDB(t) + srv := NewServer(db, testExternalURL) + + t.Run("NewServer", func(t *testing.T) { + location := NewServer(nil, "https://auth.example.com/").serviceProviderConfig.Meta.Location + + require.Equal(t, "https://auth.example.com/scim/v2/ServiceProviderConfig", location) }) -} -func TestServer(t *testing.T) { - srv := newServerFor("http://localhost:9999") - require.NotNil(t, srv) + t.Run("Tenant", func(t *testing.T) { + tenant := createTenant(t, db) + _, raw := createToken(t, db, tenant) + + served := func(t *testing.T, authorization string) (*httptest.ResponseRecorder, *Tenant) { + t.Helper() + + var seen *Tenant + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = tenantKey.Value(r.Context()) + w.WriteHeader(http.StatusTeapot) + }) + + r := httptest.NewRequest(http.MethodGet, BasePath+"/Users", nil) + if authorization != "" { + r.Header.Set("Authorization", authorization) + } + + w := httptest.NewRecorder() + srv.Tenant(next).ServeHTTP(w, r) + return w, seen + } + + t.Run("hands the tenant to the handler", func(t *testing.T) { + w, seen := served(t, "Bearer "+raw) + + assert.Equal(t, http.StatusTeapot, w.Code) + assert.Equal(t, tenant, seen) + }) - t.Run("NewServer trims a trailing slash from the external URL", func(t *testing.T) { - location := newServerFor("https://auth.example.com/").serviceProviderConfig.Meta.Location + t.Run("returns 401 with a challenge when the token is unknown", func(t *testing.T) { + unknown, _ := models.NewSCIMBearerToken() - require.Equal(t, "https://auth.example.com"+BasePath+"/ServiceProviderConfig", location) + w, seen := served(t, "Bearer "+unknown) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, `Bearer realm="SCIM"`, w.Header().Get("WWW-Authenticate")) + assert.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + assert.Empty(t, seen) + }) + + t.Run("returns 401 when there is no Authorization header", func(t *testing.T) { + w, seen := served(t, "") + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Empty(t, seen) + }) + + t.Run("returns 401 when token is revoked", func(t *testing.T) { + token, raw := createToken(t, db, tenant) + revokedAt := time.Now() + token.RevokedAt = &revokedAt + require.NoError(t, db.Update(token)) + + w, seen := served(t, "Bearer "+raw) + assert.Empty(t, seen) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, `Bearer realm="SCIM"`, w.Header().Get("WWW-Authenticate")) + assert.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + }) }) - t.Run("ServiceProviderConfig", func(t *testing.T) { - r := httptest.NewRequest(http.MethodGet, BasePath+"/ServiceProviderConfig", nil) + t.Run("GET /ServiceProviderConfig", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/scim/v2/ServiceProviderConfig", nil) w := httptest.NewRecorder() require.NoError(t, srv.ServiceProviderConfig(w, r)) @@ -48,44 +114,408 @@ func TestServer(t *testing.T) { require.JSONEq(t, testFixture(t, "service_provider_config.json"), w.Body.String()) }) - for _, tc := range []struct { - path string - handler func(http.ResponseWriter, *http.Request) error - }{ - {"ResourceTypes", srv.ResourceTypes}, - {"Schemas", srv.Schemas}, - } { - t.Run(tc.path, func(t *testing.T) { - r := httptest.NewRequest(http.MethodGet, BasePath+"/"+tc.path, nil) + t.Run("GET /ResourceTypes", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/scim/v2/ResourceTypes", nil) + w := httptest.NewRecorder() + + require.NoError(t, srv.ResourceTypes(w, r)) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, testFixture(t, "resource_types.json"), w.Body.String()) + }) + + t.Run("GET /ResourceTypes?filter={name eq User}", func(t *testing.T) { + filter := url.Values{"filter": {`name eq "User"`}}.Encode() + r := httptest.NewRequest(http.MethodGet, "/scim/v2/ResourceTypes"+"?"+filter, nil) + w := httptest.NewRecorder() + + require.NoError(t, srv.ResourceTypes(w, r)) + + require.Equal(t, http.StatusForbidden, w.Code) + require.JSONEq(t, testFixture(t, "filter_forbidden.json"), w.Body.String()) + }) + + t.Run("GET /ResourceTypes/User", func(t *testing.T) { + r := requestWithURLParam("/ResourceTypes/User", "id", "User") + w := httptest.NewRecorder() + + require.NoError(t, srv.ResourceTypeByID(w, r)) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, testFixture(t, "resource_type_user.json"), w.Body.String()) + }) + + t.Run("GET /ResourceTypes/Unknown", func(t *testing.T) { + r := requestWithURLParam("/ResourceTypes/Unknown", "id", "Unknown") + w := httptest.NewRecorder() + + require.NoError(t, srv.ResourceTypeByID(w, r)) + + require.Equal(t, http.StatusNotFound, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, testFixture(t, "not_found.json"), w.Body.String()) + }) + + t.Run("GET /Schemas", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/scim/v2/Schemas", nil) + w := httptest.NewRecorder() + + require.NoError(t, srv.Schemas(w, r)) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, testFixture(t, "schemas.json"), w.Body.String()) + }) + + t.Run("GET /Schemas?filter={name eq User}", func(t *testing.T) { + filter := url.Values{"filter": {`name eq "User"`}}.Encode() + r := httptest.NewRequest(http.MethodGet, "/scim/v2/Schemas"+"?"+filter, nil) + w := httptest.NewRecorder() + + require.NoError(t, srv.Schemas(w, r)) + + require.Equal(t, http.StatusForbidden, w.Code) + require.JSONEq(t, testFixture(t, "filter_forbidden.json"), w.Body.String()) + }) + + t.Run("GET /Schemas/User", func(t *testing.T) { + r := requestWithURLParam("/Schemas/urn:ietf:params:scim:schemas:core:2.0:User", "id", "urn:ietf:params:scim:schemas:core:2.0:User") + w := httptest.NewRecorder() + + require.NoError(t, srv.SchemaByID(w, r)) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, testFixture(t, "schema_user.json"), w.Body.String()) + }) + + t.Run("GET /Schemas/Unknown", func(t *testing.T) { + r := requestWithURLParam("/Schemas/Unknown", "id", "Unknown") + w := httptest.NewRecorder() + + require.NoError(t, srv.SchemaByID(w, r)) + + require.Equal(t, http.StatusNotFound, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, testFixture(t, "not_found.json"), w.Body.String()) + }) + + t.Run("GET /scim/v2/Unknown", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/scim/v2/Unknown", nil) + w := httptest.NewRecorder() + + require.NoError(t, srv.NotFound(w, r)) + + require.Equal(t, http.StatusNotFound, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, testFixture(t, "not_found.json"), w.Body.String()) + }) + + t.Run("GET /Users", func(t *testing.T) { + t.Run("?startIndex=first", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/scim/v2/Users?startIndex=first", nil) w := httptest.NewRecorder() - require.NoError(t, tc.handler(w, r)) + require.NoError(t, srv.Users(w, r)) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + + var body protocol.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, protocol.ScimTypeInvalidValue, body.ScimType) + }) + + t.Run("?count=all", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/scim/v2/Users?count=all", nil) + w := httptest.NewRecorder() + + require.NoError(t, srv.Users(w, r)) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + + var body protocol.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, protocol.ScimTypeInvalidValue, body.ScimType) + }) + + t.Run("?startIndex=2&count=2", func(t *testing.T) { + get := usersFor(t, srv, db, "a", "b", "c", "d", "e") + + w := get("startIndex=2&count=2") + body := listed[*core.User](t, w) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, 5, body.TotalResults) + assert.Equal(t, 2, body.StartIndex) + assert.Equal(t, 2, body.ItemsPerPage) + assert.Len(t, body.Resources, 2) + }) + + t.Run("?count=0", func(t *testing.T) { + get := usersFor(t, srv, db, "a", "b", "c") + + w := get("count=0") + body := listed[*core.User](t, w) require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, 3, body.TotalResults) + assert.Equal(t, 0, body.ItemsPerPage) + assert.Empty(t, body.Resources) + assert.Contains(t, w.Body.String(), `"Resources":[]`) + }) + + t.Run("sortBy=userName", func(t *testing.T) { + get := usersFor(t, srv, db, "carol", "alice", "bob") + + ascending := listed[*core.User](t, get("sortBy=userName")) + descending := listed[*core.User](t, get("sortBy=userName&sortOrder=descending")) + + assert.Equal(t, []string{"alice", "bob", "carol"}, userNamesOf(ascending.Resources)) + assert.Equal(t, []string{"carol", "bob", "alice"}, userNamesOf(descending.Resources)) + }) + + t.Run("?sortBy=nickName", func(t *testing.T) { + get := usersFor(t, srv, db, "a") + + w := get("sortBy=nickName") + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + assert.Contains(t, w.Body.String(), string(protocol.ScimTypeInvalidValue)) + assert.Contains(t, w.Body.String(), "nickName") + }) + + t.Run("?sortBy=userName&sortOrder=sideways", func(t *testing.T) { + get := usersFor(t, srv, db, "a") + + w := get("sortBy=userName&sortOrder=sideways") + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), string(protocol.ScimTypeInvalidValue)) + }) + + t.Run("?filter is rejected", func(t *testing.T) { + get := usersFor(t, srv, db, "alice", "bob", "carol") + + w := get(filterQuery(`userName eq "bob"`)) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + assert.Contains(t, w.Body.String(), string(protocol.ScimTypeInvalidFilter)) + }) + }) + + create := func(t *testing.T, srv *Server, tenant *Tenant, userName string) *core.User { + t.Helper() + r := scimRequest(http.MethodPost, "/Users", `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"`+userName+`"}`, tenant, nil) + w := httptest.NewRecorder() + require.NoError(t, srv.CreateUser(w, r)) + require.Equal(t, http.StatusCreated, w.Code) + + var user core.User + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &user)) + return &user + } + + t.Run("POST /Users", func(t *testing.T) { + t.Run("with valid parameters", func(t *testing.T) { + tenant := createTenant(t, db) + + r := scimRequest(http.MethodPost, "/Users", `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"bjensen"}`, tenant, nil) + w := httptest.NewRecorder() + require.NoError(t, srv.CreateUser(w, r)) + + require.Equal(t, http.StatusCreated, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + + var user core.User + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &user)) + assert.NotEmpty(t, user.ID) + assert.Equal(t, "bjensen", user.UserName) + assert.Equal(t, testExternalURL+BasePath+"/Users/"+user.ID, w.Header().Get("Location")) + }) + + t.Run("defaults active to true when omitted", func(t *testing.T) { + tenant := createTenant(t, db) + + r := scimRequest(http.MethodPost, "/Users", `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"bjensen"}`, tenant, nil) + w := httptest.NewRecorder() + require.NoError(t, srv.CreateUser(w, r)) + + require.Equal(t, http.StatusCreated, w.Code) + + var user core.User + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &user)) + require.NotNil(t, user.Active) + assert.True(t, *user.Active) + }) + + t.Run("without a userName", func(t *testing.T) { + tenant := createTenant(t, db) + + r := scimRequest(http.MethodPost, "/Users", `{"externalId":"ext-1"}`, tenant, nil) + w := httptest.NewRecorder() + require.NoError(t, srv.CreateUser(w, r)) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), string(protocol.ScimTypeInvalidValue)) + }) + + t.Run("without schemas", func(t *testing.T) { + tenant := createTenant(t, db) + + r := scimRequest(http.MethodPost, "/Users", `{"userName":"bjensen"}`, tenant, nil) + w := httptest.NewRecorder() + require.NoError(t, srv.CreateUser(w, r)) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), string(protocol.ScimTypeInvalidValue)) + }) + + t.Run("with a malformed body", func(t *testing.T) { + tenant := createTenant(t, db) + + r := scimRequest(http.MethodPost, "/Users", `{"userName":`, tenant, nil) + w := httptest.NewRecorder() + require.NoError(t, srv.CreateUser(w, r)) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), string(protocol.ScimTypeInvalidSyntax)) + }) + + t.Run("with an oversized request body", func(t *testing.T) { + tenant := createTenant(t, db) + + body := fmt.Sprintf(`{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"%s"}`, strings.Repeat("x", 64)) + r := httptest. + NewRequest(http.MethodPost, "/scim/v2/Users", strings.NewReader(body)). + WithContext(tenantKey.WithValue(t.Context(), tenant)) + r.Body = http.MaxBytesReader(httptest.NewRecorder(), r.Body, 8) + + w := httptest.NewRecorder() + + require.NoError(t, srv.CreateUser(w, r)) + + require.Equal(t, http.StatusRequestEntityTooLarge, w.Code) require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) - require.JSONEq(t, testFixture(t, "empty_list_response.json"), w.Body.String()) + }) + }) + + t.Run("PUT /Users/{id}", func(t *testing.T) { + t.Run("replaces a User's attributes", func(t *testing.T) { + tenant := createTenant(t, db) + created := create(t, srv, tenant, "carol") + + body := `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"carol-renamed"}` + r := scimRequest(http.MethodPut, "/Users/"+created.ID, body, tenant, map[string]string{"id": created.ID}) + w := httptest.NewRecorder() + require.NoError(t, srv.ReplaceUser(w, r)) + + require.Equal(t, http.StatusOK, w.Code) + var user core.User + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &user)) + assert.Equal(t, created.ID, user.ID) + assert.Equal(t, "carol-renamed", user.UserName) + }) + + t.Run("with an unknown id", func(t *testing.T) { + tenant := createTenant(t, db) + id := uuid.Must(uuid.NewV4()).String() + + body := `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"ghost"}` + r := scimRequest(http.MethodPut, "/Users/"+id, body, tenant, map[string]string{"id": id}) + w := httptest.NewRecorder() + require.NoError(t, srv.ReplaceUser(w, r)) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + }) + + t.Run("DELETE /Users", func(t *testing.T) { + t.Run("removes a User", func(t *testing.T) { + tenant := createTenant(t, db) + created := create(t, srv, tenant, "eve") + + r := scimRequest(http.MethodDelete, "/Users/"+created.ID, "", tenant, map[string]string{"id": created.ID}) + w := httptest.NewRecorder() + require.NoError(t, srv.DeleteUser(w, r)) + + require.Equal(t, http.StatusNoContent, w.Code) + assert.Empty(t, w.Body.String()) + + get := scimRequest(http.MethodGet, "/Users/"+created.ID, "", tenant, map[string]string{"id": created.ID}) + gw := httptest.NewRecorder() + require.NoError(t, srv.UserByID(gw, get)) + assert.Equal(t, http.StatusNotFound, gw.Code) }) - t.Run(tc.path+" rejects filter query parameter", func(t *testing.T) { - filter := url.Values{"filter": {`name eq "User"`}}.Encode() - r := httptest.NewRequest(http.MethodGet, BasePath+"/"+tc.path+"?"+filter, nil) + t.Run("with an unknown", func(t *testing.T) { + tenant := createTenant(t, db) + id := uuid.Must(uuid.NewV4()).String() + + r := scimRequest(http.MethodDelete, "/Users/"+id, "", tenant, map[string]string{"id": id}) w := httptest.NewRecorder() + require.NoError(t, srv.DeleteUser(w, r)) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + }) +} + +func requestWithURLParam(path, key, value string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/scim/v2/"+path, nil) + + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add(key, value) + + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx)) +} + +func filterQuery(filter string) string { + return url.Values{"filter": {filter}}.Encode() +} - require.NoError(t, tc.handler(w, r)) +func scimRequest(method, target, body string, tenant *Tenant, params map[string]string) *http.Request { + r := httptest.NewRequest(method, BasePath+target, strings.NewReader(body)) - require.Equal(t, http.StatusForbidden, w.Code) - require.JSONEq(t, testFixture(t, "filter_forbidden.json"), w.Body.String()) + routeCtx := chi.NewRouteContext() + for key, value := range params { + routeCtx.URLParams.Add(key, value) + } + + ctx := context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx) + return r.WithContext(tenantKey.WithValue(ctx, tenant)) +} + +func usersFor(t *testing.T, srv *Server, db *storage.Connection, userNames ...string) func(query string) *httptest.ResponseRecorder { + t.Helper() + + tenant := createTenant(t, db) + for _, userName := range userNames { + createUser(t, db, tenant, &core.User{ + ID: uuid.Must(uuid.NewV4()).String(), + UserName: userName, }) } - t.Run("NotFound", func(t *testing.T) { - r := httptest.NewRequest(http.MethodGet, BasePath+"/Unknown", nil) + return func(query string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, BasePath+"/Users?"+query, nil) + r = r.WithContext(tenantKey.WithValue(r.Context(), tenant)) + w := httptest.NewRecorder() + require.NoError(t, srv.Users(w, r)) + return w + } +} - require.NoError(t, srv.NotFound(w, r)) +func listed[T any](t *testing.T, w *httptest.ResponseRecorder) protocol.ListResponse[T] { + t.Helper() - require.Equal(t, http.StatusNotFound, w.Code) - require.Equal(t, "application/scim+json", w.Header().Get("Content-Type")) - require.JSONEq(t, testFixture(t, "not_found.json"), w.Body.String()) - }) + var body protocol.ListResponse[T] + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + return body } diff --git a/internal/api/scim/tenant.go b/internal/api/scim/tenant.go new file mode 100644 index 0000000000..eeb282fb60 --- /dev/null +++ b/internal/api/scim/tenant.go @@ -0,0 +1,10 @@ +package scim + +import ( + "github.com/supabase/auth/internal/ctxkey" + "github.com/supabase/auth/internal/models" +) + +type Tenant = models.SSOProvider + +var tenantKey = ctxkey.New[*Tenant]("tenant") diff --git a/internal/api/scim/testdata/empty_list_response.json b/internal/api/scim/testdata/empty_list_response.json deleted file mode 100644 index d13e376c64..0000000000 --- a/internal/api/scim/testdata/empty_list_response.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "schemas": [ - "urn:ietf:params:scim:api:messages:2.0:ListResponse" - ], - "totalResults": 0, - "startIndex": 1, - "itemsPerPage": 0, - "Resources": [] -} diff --git a/internal/api/scim/testdata/resource_type_user.json b/internal/api/scim/testdata/resource_type_user.json new file mode 100644 index 0000000000..3a47b42678 --- /dev/null +++ b/internal/api/scim/testdata/resource_type_user.json @@ -0,0 +1,14 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:ResourceType" + ], + "id": "User", + "name": "User", + "description": "User Account", + "endpoint": "/Users", + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "meta": { + "resourceType": "ResourceType", + "location": "http://localhost:9999/scim/v2/ResourceTypes/User" + } +} diff --git a/internal/api/scim/testdata/resource_types.json b/internal/api/scim/testdata/resource_types.json new file mode 100644 index 0000000000..07f4496642 --- /dev/null +++ b/internal/api/scim/testdata/resource_types.json @@ -0,0 +1,24 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ], + "totalResults": 1, + "startIndex": 1, + "itemsPerPage": 1, + "Resources": [ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:ResourceType" + ], + "id": "User", + "name": "User", + "description": "User Account", + "endpoint": "/Users", + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "meta": { + "resourceType": "ResourceType", + "location": "http://localhost:9999/scim/v2/ResourceTypes/User" + } + } + ] +} diff --git a/internal/api/scim/testdata/schema_user.json b/internal/api/scim/testdata/schema_user.json new file mode 100644 index 0000000000..47159dcd7f --- /dev/null +++ b/internal/api/scim/testdata/schema_user.json @@ -0,0 +1,128 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Schema" + ], + "id": "urn:ietf:params:scim:schemas:core:2.0:User", + "name": "User", + "description": "User Account", + "attributes": [ + { + "name": "userName", + "type": "string", + "multiValued": false, + "description": "Unique identifier for the User", + "required": true, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "server" + }, + { + "name": "name", + "type": "complex", + "multiValued": false, + "description": "The components of the user's name.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "formatted", + "type": "string", + "multiValued": false, + "description": "The name formatted for display.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "familyName", + "type": "string", + "multiValued": false, + "description": "The family name of the User.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "givenName", + "type": "string", + "multiValued": false, + "description": "The given name of the User.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "middleName", + "type": "string", + "multiValued": false, + "description": "The middle name(s) of the User.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "emails", + "type": "complex", + "multiValued": true, + "description": "Email addresses for the user.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "value", + "type": "string", + "multiValued": false, + "description": "An email address for the user.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "primary", + "type": "boolean", + "multiValued": false, + "description": "The 'primary' email address", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "active", + "type": "boolean", + "multiValued": false, + "description": "", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + } + ], + "meta": { + "resourceType": "Schema", + "location": "http://localhost:9999/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User" + } +} diff --git a/internal/api/scim/testdata/schemas.json b/internal/api/scim/testdata/schemas.json new file mode 100644 index 0000000000..f4ee373142 --- /dev/null +++ b/internal/api/scim/testdata/schemas.json @@ -0,0 +1,138 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ], + "totalResults": 1, + "startIndex": 1, + "itemsPerPage": 1, + "Resources": [ + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Schema" + ], + "id": "urn:ietf:params:scim:schemas:core:2.0:User", + "name": "User", + "description": "User Account", + "attributes": [ + { + "name": "userName", + "type": "string", + "multiValued": false, + "description": "Unique identifier for the User", + "required": true, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "server" + }, + { + "name": "name", + "type": "complex", + "multiValued": false, + "description": "The components of the user's name.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "formatted", + "type": "string", + "multiValued": false, + "description": "The name formatted for display.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "familyName", + "type": "string", + "multiValued": false, + "description": "The family name of the User.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "givenName", + "type": "string", + "multiValued": false, + "description": "The given name of the User.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "middleName", + "type": "string", + "multiValued": false, + "description": "The middle name(s) of the User.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "emails", + "type": "complex", + "multiValued": true, + "description": "Email addresses for the user.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "value", + "type": "string", + "multiValued": false, + "description": "An email address for the user.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "primary", + "type": "boolean", + "multiValued": false, + "description": "The 'primary' email address", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "active", + "type": "boolean", + "multiValued": false, + "description": "", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + } + ], + "meta": { + "resourceType": "Schema", + "location": "http://localhost:9999/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User" + } + } + ] +} diff --git a/internal/api/scim/testdata/service_provider_config.json b/internal/api/scim/testdata/service_provider_config.json index 22b2337714..2220eeb3a4 100644 --- a/internal/api/scim/testdata/service_provider_config.json +++ b/internal/api/scim/testdata/service_provider_config.json @@ -18,7 +18,7 @@ "supported": false }, "sort": { - "supported": false + "supported": true }, "etag": { "supported": false diff --git a/internal/api/scim/user_repository.go b/internal/api/scim/user_repository.go new file mode 100644 index 0000000000..fb152de54d --- /dev/null +++ b/internal/api/scim/user_repository.go @@ -0,0 +1,213 @@ +package scim + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/gofrs/uuid" + "github.com/jackc/pgconn" + "github.com/jackc/pgerrcode" + "github.com/supabase-community/scim-go/pkg/core" + "github.com/supabase-community/scim-go/pkg/protocol" + "github.com/supabase/auth/internal/storage" +) + +var ErrNotFound = errors.New("scim: resource not found") + +var userSortColumns = map[string]string{ + "id": "id", + "username": `lower(user_name collate "C")`, + "meta.created": "created_at", + "meta.lastmodified": "updated_at", +} + +const countUsers = `SELECT COUNT(*) FROM scim_users WHERE sso_provider_id = ? AND deleted_at IS NULL` + +const listUsers = `SELECT id, resource, active, created_at, updated_at FROM scim_users WHERE sso_provider_id = ? AND deleted_at IS NULL ORDER BY %s LIMIT ? OFFSET ?` + +type scimUser struct { + ID string `db:"id"` + Resource []byte `db:"resource"` + Active bool `db:"active"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (scimUser) TableName() string { + return "scim_users" +} + +type userRepository struct { + db *storage.Connection + baseURL string +} + +func NewUserRepository(db *storage.Connection, baseURL string) Repository[*core.User] { + return &userRepository{ + db: db, + baseURL: baseURL, + } +} + +func (r *userRepository) List(ctx context.Context, query *protocol.SearchRequest) ([]*core.User, int, error) { + if query.Filter != "" { + return nil, 0, protocol.ErrInvalidFilter("filtering is not supported") + } + + orderBy, err := r.orderBy(query) + if err != nil { + return nil, 0, err + } + + db := r.db.WithContext(ctx) + tenant := r.tenant(ctx) + + var total int + if err := db.RawQuery(countUsers, tenant).First(&total); err != nil { + return nil, 0, fmt.Errorf("scim: counting users: %w", err) + } + + if query.Count <= 0 { + return nil, total, nil + } + + var rows []scimUser + if err := db.RawQuery(fmt.Sprintf(listUsers, orderBy), tenant, query.Count, query.Offset()).All(&rows); err != nil { + return nil, 0, fmt.Errorf("scim: listing users: %w", err) + } + + users := make([]*core.User, 0, len(rows)) + for _, row := range rows { + user, err := r.mapFrom(&row) + if err != nil { + return nil, 0, err + } + users = append(users, user) + } + return users, total, nil +} + +func (r *userRepository) Get(ctx context.Context, id string) (*core.User, error) { + var rows []scimUser + + err := r.db.WithContext(ctx).RawQuery("SELECT id, resource, active, created_at, updated_at FROM scim_users WHERE sso_provider_id = ? AND deleted_at IS NULL AND id = ?", r.tenant(ctx), id).All(&rows) + if err != nil { + return nil, fmt.Errorf("scim: reading user: %w", err) + } + + if len(rows) == 0 { + return nil, ErrNotFound + } + return r.mapFrom(&rows[0]) +} + +func (r *userRepository) Create(ctx context.Context, user *core.User) (*core.User, error) { + resource, err := r.toResource(user) + if err != nil { + return nil, err + } + + var rows []scimUser + if err := r.db.WithContext(ctx).RawQuery("INSERT INTO scim_users (id, sso_provider_id, resource) VALUES (?, ?, ?) RETURNING id, resource, active, created_at, updated_at", uuid.Must(uuid.NewV4()), r.tenant(ctx), resource).All(&rows); err != nil { + return nil, r.buildError("creating", err) + } + return r.mapFrom(&rows[0]) +} + +func (r *userRepository) Replace(ctx context.Context, id string, user *core.User) (*core.User, error) { + resource, err := r.toResource(user) + if err != nil { + return nil, err + } + + var rows []scimUser + if err := r.db.WithContext(ctx).RawQuery("UPDATE scim_users SET resource = jsonb_set(?::jsonb, '{active}', coalesce(?::jsonb -> 'active', to_jsonb(active))), updated_at = now() WHERE sso_provider_id = ? AND deleted_at IS NULL AND id = ? RETURNING id, resource, active, created_at, updated_at", string(resource), string(resource), r.tenant(ctx), id).All(&rows); err != nil { + return nil, r.buildError("replacing", err) + } + if len(rows) == 0 { + return nil, ErrNotFound + } + return r.mapFrom(&rows[0]) +} + +func (r *userRepository) Delete(ctx context.Context, id string) error { + var ids []string + if err := r.db.WithContext(ctx).RawQuery("UPDATE scim_users SET deleted_at = now() WHERE sso_provider_id = ? AND deleted_at IS NULL AND id = ? RETURNING id", r.tenant(ctx), id).All(&ids); err != nil { + return fmt.Errorf("scim: deleting user: %w", err) + } + if len(ids) == 0 { + return ErrNotFound + } + return nil +} + +func (r *userRepository) toResource(user *core.User) ([]byte, error) { + stored := *user + stored.ID = "" + stored.Meta = core.Meta{} + + resource, err := json.Marshal(&stored) + if err != nil { + return nil, fmt.Errorf("scim: encoding user: %w", err) + } + return resource, nil +} + +func (r *userRepository) buildError(action string, err error) error { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { + return protocol.ErrUniqueness("a User with this userName already exists") + } + return fmt.Errorf("scim: %s user: %w", action, err) +} + +func (r *userRepository) mapFrom(row *scimUser) (*core.User, error) { + user := new(core.User) + if err := json.Unmarshal(row.Resource, user); err != nil { + return nil, fmt.Errorf("scim: decoding stored user %s: %w", row.ID, err) + } + + user.ID = row.ID + user.Active = &row.Active + user.Meta = core.Meta{ + ResourceType: "User", + Created: row.CreatedAt.UTC(), + LastModified: row.UpdatedAt.UTC(), + Location: Join(Join(r.baseURL, "/Users"), row.ID), + } + + if len(user.Schemas) == 0 { + user.Schemas = []core.SchemaURI{core.SchemaUser} + } + return user, nil +} + +func (r *userRepository) tenant(ctx context.Context) string { + return tenantKey.Value(ctx).ID.String() +} + +func (r *userRepository) orderBy(query *protocol.SearchRequest) (string, error) { + column := "id" + if query.SortBy != "" { + sortable, ok := userSortColumns[strings.ToLower(query.SortBy)] + if !ok { + return "", protocol.ErrInvalidValue(strconv.Quote(query.SortBy) + " is not an attribute this resource can be sorted by") + } + column = sortable + } + + direction := " ASC" + if query.Descending() { + direction = " DESC" + } + + if column == "id" { + return column + direction, nil + } + return column + direction + ", id" + direction, nil +} diff --git a/internal/api/scim/user_repository_test.go b/internal/api/scim/user_repository_test.go new file mode 100644 index 0000000000..23fb5e4661 --- /dev/null +++ b/internal/api/scim/user_repository_test.go @@ -0,0 +1,278 @@ +package scim + +import ( + "slices" + "strings" + "testing" + "time" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase-community/scim-go/pkg/core" + "github.com/supabase-community/scim-go/pkg/protocol" +) + +func TestUserRepository(t *testing.T) { + user := func(name string) *core.User { + t.Helper() + + return &core.User{ + Schemas: []core.SchemaURI{core.SchemaUser}, + UserName: name, + } + } + + db := newTestDB(t) + owner := createTenant(t, db) + ctx := tenantKey.WithValue(t.Context(), owner) + repository := NewUserRepository(db, Join(testExternalURL, BasePath)) + + t.Run("List", func(t *testing.T) { + users := seedUsers() + count := len(users) + for _, user := range users { + createUser(t, db, owner, user) + } + + page := func(t *testing.T, query *protocol.SearchRequest) ([]*core.User, int) { + t.Helper() + + users, total, err := repository.List(ctx, query) + require.NoError(t, err) + return users, total + } + + for _, sortBy := range []string{"", "id", "userName", "meta.created", "meta.lastModified"} { + for _, order := range []protocol.SortOrder{protocol.SortAscending, protocol.SortDescending} { + t.Run("sortBy="+sortBy+" sortOrder="+string(order), func(t *testing.T) { + whole, total := page(t, &protocol.SearchRequest{ + StartIndex: 1, Count: count, SortBy: sortBy, SortOrder: order, + }) + require.Equal(t, count, total) + require.Len(t, whole, count) + + expected := idsOf(whole) + + for size := 1; size <= count+1; size++ { + var walked []string + for start := 1; start <= count; start += size { + users, _ := page(t, &protocol.SearchRequest{ + StartIndex: start, Count: size, SortBy: sortBy, SortOrder: order, + }) + walked = append(walked, idsOf(users)...) + } + + assert.Equal(t, expected, walked) + assert.Len(t, slices.Compact(slices.Sorted(slices.Values(walked))), count) + } + }) + } + } + + t.Run("reverses the whole order when asked to descend", func(t *testing.T) { + // meta.created ties every resource, so only a tiebreaker that reverses + // with the sort makes these two the reverse of one another. + for _, sortBy := range []string{"id", "meta.created"} { + t.Run(sortBy, func(t *testing.T) { + ascending, _ := page(t, &protocol.SearchRequest{StartIndex: 1, Count: count, SortBy: sortBy}) + descending, _ := page(t, &protocol.SearchRequest{ + StartIndex: 1, Count: count, SortBy: sortBy, SortOrder: protocol.SortDescending, + }) + + slices.Reverse(descending) + assert.Equal(t, idsOf(ascending), idsOf(descending)) + }) + } + }) + + t.Run("reports the total without a page when no resources are wanted", func(t *testing.T) { + users, total := page(t, &protocol.SearchRequest{StartIndex: 1, Count: 0}) + + assert.Empty(t, users) + assert.Equal(t, count, total) + }) + + t.Run("reports the total of every match, not of the page", func(t *testing.T) { + users, total := page(t, &protocol.SearchRequest{StartIndex: 1, Count: 5}) + + assert.Len(t, users, 5) + assert.Equal(t, count, total) + }) + + t.Run("returns nothing beyond the end of the collection", func(t *testing.T) { + users, total := page(t, &protocol.SearchRequest{StartIndex: count + 50, Count: 10}) + + assert.Empty(t, users) + assert.Equal(t, count, total) + }) + + t.Run("orders userName as a lowercased code point comparison", func(t *testing.T) { + users, _ := page(t, &protocol.SearchRequest{StartIndex: 1, Count: count, SortBy: "userName"}) + names := userNamesOf(users) + + expected := slices.Clone(names) + slices.SortFunc(expected, func(a, b string) int { + return strings.Compare(strings.ToLower(a), strings.ToLower(b)) + }) + + require.Len(t, names, count) + assert.Equal(t, expected, names) + }) + + t.Run("names the sort attribute case insensitively, per RFC 7643 Section 2.1", func(t *testing.T) { + lower, _ := page(t, &protocol.SearchRequest{StartIndex: 1, Count: count, SortBy: "userName"}) + upper, _ := page(t, &protocol.SearchRequest{StartIndex: 1, Count: count, SortBy: "USERNAME"}) + + assert.Equal(t, idsOf(lower), idsOf(upper)) + }) + + t.Run("refuses to sort by an attribute it cannot order", func(t *testing.T) { + _, _, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: 10, SortBy: "nickName"}) + + require.ErrorIs(t, err, protocol.ErrInvalidValue("")) + assert.Contains(t, err.Error(), "nickName") + }) + + t.Run("refuses any filter", func(t *testing.T) { + _, _, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `userName eq "alice@example.com"`}) + require.ErrorIs(t, err, protocol.ErrInvalidFilter("")) + }) + }) + + t.Run("Get", func(t *testing.T) { + t.Run("reads the resource back", func(t *testing.T) { + created, err := repository.Create(ctx, user("bob")) + require.NoError(t, err) + + got, err := repository.Get(ctx, created.ID) + require.NoError(t, err) + + assert.Equal(t, created.ID, got.ID) + assert.Equal(t, "bob", got.UserName) + require.NotNil(t, got.Active) + assert.True(t, *got.Active) + assert.Equal(t, created.Meta.Created, got.Meta.Created) + assert.Equal(t, created.Meta.LastModified, got.Meta.LastModified) + }) + }) + + t.Run("Create", func(t *testing.T) { + t.Run("assigns an id and preserves the attributes", func(t *testing.T) { + created, err := repository.Create(ctx, user("alice")) + require.NoError(t, err) + assert.NotEmpty(t, created.ID) + assert.Equal(t, "alice", created.UserName) + }) + }) + + t.Run("Replace", func(t *testing.T) { + t.Run("changes attributes and keeps the id", func(t *testing.T) { + created, err := repository.Create(ctx, user("carol")) + require.NoError(t, err) + + replaced, err := repository.Replace(ctx, created.ID, user("carol-renamed")) + require.NoError(t, err) + assert.Equal(t, created.ID, replaced.ID) + assert.Equal(t, "carol-renamed", replaced.UserName) + + got, err := repository.Get(ctx, created.ID) + require.NoError(t, err) + assert.Equal(t, "carol-renamed", got.UserName) + }) + + t.Run("keeps active when the body omits it", func(t *testing.T) { + ctx := tenantKey.WithValue(t.Context(), createTenant(t, db)) + + created, err := repository.Create(ctx, user("gilfoyle")) + require.NoError(t, err) + require.NotNil(t, created.Active) + require.True(t, *created.Active) + + require.NoError(t, db.RawQuery("UPDATE scim_users SET resource = jsonb_set(resource, '{active}', 'false') WHERE id = ?", created.ID).Exec()) + + replaced, err := repository.Replace(ctx, created.ID, user("gilfoyle-renamed")) + require.NoError(t, err) + assert.Equal(t, "gilfoyle-renamed", replaced.UserName) + require.NotNil(t, replaced.Active) + assert.False(t, *replaced.Active) + }) + + t.Run("replaces active supplied in the body, per RFC 7644 3.5.1", func(t *testing.T) { + ctx := tenantKey.WithValue(t.Context(), createTenant(t, db)) + + created, err := repository.Create(ctx, user("dinesh")) + require.NoError(t, err) + + require.NoError(t, db.RawQuery("UPDATE scim_users SET resource = jsonb_set(resource, '{active}', 'false') WHERE id = ?", created.ID).Exec()) + + active := true + reactivating := &core.User{Schemas: []core.SchemaURI{core.SchemaUser}, UserName: "dinesh", Active: &active} + + replaced, err := repository.Replace(ctx, created.ID, reactivating) + require.NoError(t, err) + require.NotNil(t, replaced.Active) + assert.True(t, *replaced.Active) + }) + + t.Run("writing an unknown id is ErrNotFound", func(t *testing.T) { + missing := uuid.Must(uuid.NewV4()).String() + + _, err := repository.Get(ctx, missing) + require.ErrorIs(t, err, ErrNotFound) + + _, err = repository.Replace(ctx, missing, user("ghost")) + require.ErrorIs(t, err, ErrNotFound) + + require.ErrorIs(t, repository.Delete(ctx, missing), ErrNotFound) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("unlists resource", func(t *testing.T) { + created, err := repository.Create(ctx, user("eve")) + require.NoError(t, err) + + require.NoError(t, repository.Delete(ctx, created.ID)) + + _, err = repository.Get(ctx, created.ID) + require.ErrorIs(t, err, ErrNotFound) + }) + }) +} + +func seedUsers() []*core.User { + users := []*core.User{} + for _, userName := range []string{ + "Zoe@example.com", + "alice@example.com", + "a-z@example.com", + "ab@example.com", + "BJensen@example.com", + "bob@example.com", + "carol1@example.com", + "carol-1@example.com", + "Dave@example.com", + "eve@example.com", + "Frank@example.com", + "user-00@example.com", + } { + users = append(users, &core.User{ + ID: uuid.Must(uuid.NewV4()).String(), + UserName: userName, + Meta: core.Meta{ + Created: time.Now().Add(-1 * time.Hour).UTC(), + LastModified: time.Now().Add(-1 * time.Hour).UTC(), + }, + }) + } + return users +} + +func idsOf(users []*core.User) []string { + ids := make([]string, 0, len(users)) + for _, user := range users { + ids = append(ids, user.ID) + } + return ids +} diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index a6a966823d..8992e534c8 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -6,25 +6,36 @@ import ( "net/url" "testing" + "github.com/gofrs/uuid" "github.com/stretchr/testify/require" - scimCore "github.com/supabase/auth/internal/api/scim/core" - scimProtocol "github.com/supabase/auth/internal/api/scim/protocol" + scimCore "github.com/supabase-community/scim-go/pkg/core" + scimProtocol "github.com/supabase-community/scim-go/pkg/protocol" "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" ) const ( - scimServiceProviderConfigPath = "/scim/v2/ServiceProviderConfig" scimResourceTypesPath = "/scim/v2/ResourceTypes" scimSchemasPath = "/scim/v2/Schemas" + scimServiceProviderConfigPath = "/scim/v2/ServiceProviderConfig" + scimUserResourceTypePath = "/scim/v2/ResourceTypes/User" + scimUserSchemaPath = "/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User" + scimUsersPath = "/scim/v2/Users" ) -var scimPaths = []string{ - scimServiceProviderConfigPath, +var discoveryPaths = []string{ scimResourceTypesPath, scimSchemasPath, + scimServiceProviderConfigPath, + scimUserResourceTypePath, + scimUserSchemaPath, } +var scimPaths = append(discoveryPaths, []string{ + scimUsersPath, +}...) + func TestSCIM(t *testing.T) { t.Run("Disabled by default", func(t *testing.T) { api, _, err := setupAPIForTest() @@ -55,12 +66,18 @@ func TestSCIM(t *testing.T) { }) t.Run("Can be enabled", func(t *testing.T) { - api, _, err := setupAPIForTestWithCallback(func(config *conf.GlobalConfiguration, conn *storage.Connection) { + var conn *storage.Connection + + api, _, err := setupAPIForTestWithCallback(func(config *conf.GlobalConfiguration, db *storage.Connection) { if config != nil { config.Experimental.ScimEnabled = true } + if db != nil { + conn = db + } }) require.NoError(t, err) + require.NotNil(t, conn) require.True(t, api.config.Experimental.ScimEnabled) @@ -72,33 +89,89 @@ func TestSCIM(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) - require.Contains(t, w.Body.String(), scimCore.SchemaServiceProviderConfig) + require.Contains(t, w.Body.String(), string(scimCore.SchemaServiceProviderConfig)) }) - for _, path := range []string{scimResourceTypesPath, scimSchemasPath} { - t.Run(path, func(t *testing.T) { - r := httptest.NewRequest(http.MethodGet, path, nil) - w := httptest.NewRecorder() + t.Run(scimResourceTypesPath, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, scimResourceTypesPath, nil) + w := httptest.NewRecorder() - api.handler.ServeHTTP(w, r) + api.handler.ServeHTTP(w, r) - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) - require.Contains(t, w.Body.String(), scimProtocol.SchemaListResponse) - }) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), scimProtocol.SchemaListResponse) + }) - t.Run(path+" rejects filter query parameter", func(t *testing.T) { - filter := url.Values{"filter": {`name eq "User"`}}.Encode() - r := httptest.NewRequest(http.MethodGet, path+"?"+filter, nil) - w := httptest.NewRecorder() + t.Run(scimResourceTypesPath+" with filter", func(t *testing.T) { + filter := url.Values{"filter": {`name eq "User"`}}.Encode() + r := httptest.NewRequest(http.MethodGet, scimResourceTypesPath+"?"+filter, nil) + w := httptest.NewRecorder() - api.handler.ServeHTTP(w, r) + api.handler.ServeHTTP(w, r) - require.Equal(t, http.StatusForbidden, w.Code) - require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) - require.Contains(t, w.Body.String(), scimProtocol.SchemaError) - }) - } + require.Equal(t, http.StatusForbidden, w.Code) + require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), scimProtocol.SchemaError) + }) + + t.Run(scimUserResourceTypePath, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, scimUserResourceTypePath, nil) + w := httptest.NewRecorder() + + api.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), string(scimCore.SchemaResourceType)) + }) + + t.Run(scimSchemasPath, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, scimSchemasPath, nil) + w := httptest.NewRecorder() + + api.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), scimProtocol.SchemaListResponse) + }) + + t.Run(scimSchemasPath+" with filter", func(t *testing.T) { + filter := url.Values{"filter": {`name eq "User"`}}.Encode() + r := httptest.NewRequest(http.MethodGet, scimSchemasPath+"?"+filter, nil) + w := httptest.NewRecorder() + + api.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusForbidden, w.Code) + require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), scimProtocol.SchemaError) + }) + + t.Run(scimUserSchemaPath, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, scimUserSchemaPath, nil) + w := httptest.NewRecorder() + + api.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), string(scimCore.SchemaSchema)) + require.Contains(t, w.Body.String(), string(scimCore.SchemaUser)) + }) + + t.Run("/scim/v2/Schemas/urn%3Aietf%3Aparams%3Ascim%3Aschemas%3Acore%3A2.0%3AUser", func(t *testing.T) { + path := "/scim/v2/Schemas/urn%3Aietf%3Aparams%3Ascim%3Aschemas%3Acore%3A2.0%3AUser" + r := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + + api.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), string(scimCore.SchemaSchema)) + }) t.Run("Returns a SCIM 404 for an unknown endpoint", func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/scim/v2/Unknown", nil) @@ -113,7 +186,7 @@ func TestSCIM(t *testing.T) { t.Run("Returns a SCIM 405 for an unsupported method", func(t *testing.T) { for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { - for _, path := range scimPaths { + for _, path := range discoveryPaths { t.Run(method+" "+path, func(t *testing.T) { r := httptest.NewRequest(method, path, nil) w := httptest.NewRecorder() @@ -126,5 +199,79 @@ func TestSCIM(t *testing.T) { } } }) + + t.Run("Authentication", func(t *testing.T) { + provider, token := createProvider(t, conn) + require.NoError(t, conn.RawQuery("INSERT INTO scim_users (id, sso_provider_id, resource) VALUES (?, ?, ?)", uuid.Must(uuid.NewV4()), provider, `{"userName":"bjensen@example.com"}`).Exec()) + + get := func(t *testing.T, authorization string) *httptest.ResponseRecorder { + t.Helper() + + r := httptest.NewRequest(http.MethodGet, scimUsersPath, nil) + if authorization != "" { + r.Header.Set("Authorization", authorization) + } + + w := httptest.NewRecorder() + api.handler.ServeHTTP(w, r) + return w + } + + t.Run("serves a provider's users to its own token", func(t *testing.T) { + w := get(t, "Bearer "+token) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), "bjensen@example.com") + require.Contains(t, w.Body.String(), `"totalResults":1`) + }) + + t.Run("returns 401 when no token is offered", func(t *testing.T) { + w := get(t, "") + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Equal(t, `Bearer realm="SCIM"`, w.Header().Get("WWW-Authenticate")) + require.Contains(t, w.Body.String(), string(scimProtocol.SchemaError)) + }) + + t.Run("returns 401 to an invalid token", func(t *testing.T) { + w := get(t, "Bearer eyJhbGciOiJIUzI1NiJ9.e30.signature") + + require.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("returns 401 when the token is revoked", func(t *testing.T) { + otherProvider, token := createProvider(t, conn) + require.NoError(t, conn.RawQuery("UPDATE scim_tokens SET revoked_at = now() WHERE sso_provider_id = ?", otherProvider).Exec()) + + require.Equal(t, http.StatusUnauthorized, get(t, "Bearer "+token).Code) + }) + + t.Run("does not serve one provider's users to another's token", func(t *testing.T) { + _, otherToken := createProvider(t, conn) + + w := get(t, "Bearer "+otherToken) + + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), `"totalResults":0`) + require.NotContains(t, w.Body.String(), "bjensen@example.com") + }) + }) + }) +} + +func createProvider(t *testing.T, conn *storage.Connection) (provider, token string) { + t.Helper() + + provider = uuid.Must(uuid.NewV4()).String() + require.NoError(t, conn.RawQuery("INSERT INTO sso_providers (id, resource_id, created_at, updated_at) VALUES (?, ?, now(), now())", provider, "scim-e2e-"+provider).Exec()) + + t.Cleanup(func() { + _ = conn.RawQuery("DELETE FROM sso_providers WHERE id = ?", provider).Exec() }) + + token, digest := models.NewSCIMBearerToken() + require.NoError(t, conn.RawQuery("INSERT INTO scim_tokens (id, sso_provider_id, token_hash, prefix) VALUES (?, ?, ?, ?)", uuid.Must(uuid.NewV4()), provider, digest, token[:12]).Exec()) + + return provider, token } diff --git a/internal/api/shared/http.go b/internal/api/shared/http.go index e9c3903847..a873781ea7 100644 --- a/internal/api/shared/http.go +++ b/internal/api/shared/http.go @@ -2,9 +2,22 @@ package shared import ( "net/http" + "strings" ) +const bearerScheme = "bearer " + // SendJSON sends a JSON response with proper error handling func SendJSON(w http.ResponseWriter, status int, obj any) error { return JSON(w).ContentType("application/json").Status(status).Send(obj) } + +// Parses the bearer token a client authenticates with, per RFC 6750, Section 2.1. +func Credential(r *http.Request) string { + header := r.Header.Get("Authorization") + + if len(header) < len(bearerScheme) || !strings.EqualFold(header[:len(bearerScheme)], bearerScheme) { + return "" + } + return strings.TrimSpace(header[len(bearerScheme):]) +} diff --git a/internal/api/shared/http_test.go b/internal/api/shared/http_test.go index 1f00becaa8..cfc3f37d36 100644 --- a/internal/api/shared/http_test.go +++ b/internal/api/shared/http_test.go @@ -30,3 +30,25 @@ func TestSendJSON(t *testing.T) { assert.Equal(t, `{"key":"value"}`, w.Body.String()) }) } + +func TestCredential(t *testing.T) { + for _, tc := range []struct{ name, header, expected string }{ + {"a bearer token", "Bearer scim_abc", "scim_abc"}, + {"a lowercase scheme, per RFC 7235", "bearer scim_abc", "scim_abc"}, + {"a mixed case scheme", "BeArEr scim_abc", "scim_abc"}, + {"surrounding whitespace", "Bearer scim_abc ", "scim_abc"}, + {"no header at all", "", ""}, + {"another scheme", "Basic dXNlcjpwYXNzd29yZA==", ""}, + {"the scheme with nothing after it", "Bearer ", ""}, + {"the scheme alone", "Bearer", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/Users", nil) + if tc.header != "" { + r.Header.Set("Authorization", tc.header) + } + + assert.Equal(t, tc.expected, Credential(r)) + }) + } +} diff --git a/internal/models/scim_token.go b/internal/models/scim_token.go new file mode 100644 index 0000000000..2c3dffaa24 --- /dev/null +++ b/internal/models/scim_token.go @@ -0,0 +1,53 @@ +package models + +import ( + "crypto/sha256" + "encoding/hex" + "time" + + "github.com/gofrs/uuid" + + "github.com/supabase/auth/internal/crypto" +) + +const SCIMTokenPrefix = "scim_" +const scimTokenLength = 32 + +type SCIMToken struct { + ID uuid.UUID `db:"id"` + + SSOProvider *SSOProvider `belongs_to:"sso_providers"` + SSOProviderID uuid.UUID `db:"sso_provider_id"` + + TokenHash string `db:"token_hash"` + Prefix string `db:"prefix"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt *time.Time `db:"expires_at"` + RevokedAt *time.Time `db:"revoked_at"` + LastUsedAt *time.Time `db:"last_used_at"` +} + +func (SCIMToken) TableName() string { + return "scim_tokens" +} + +func NewSCIMToken(provider *SSOProvider) (*SCIMToken, string) { + token, digest := NewSCIMBearerToken() + return &SCIMToken{ + ID: uuid.Must(uuid.NewV4()), + SSOProviderID: provider.ID, + SSOProvider: provider, + TokenHash: digest, + Prefix: token[8:], + }, token +} + +func NewSCIMBearerToken() (token, digest string) { + token = SCIMTokenPrefix + crypto.SecureAlphanumeric(scimTokenLength) + return token, ToSCIMHash(token) +} + +func ToSCIMHash(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/models/scim_token_test.go b/internal/models/scim_token_test.go new file mode 100644 index 0000000000..5f012d1222 --- /dev/null +++ b/internal/models/scim_token_test.go @@ -0,0 +1,43 @@ +package models + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewSCIMBearerToken(t *testing.T) { + token, digest := NewSCIMBearerToken() + + t.Run("marks the token so that one is recognisable", func(t *testing.T) { + assert.Regexp(t, `^scim_`, token) + }) + + t.Run("carries 160 bits of randomness", func(t *testing.T) { + assert.Regexp(t, `^scim_[a-z2-7]{32}$`, token) + }) + + t.Run("returns the SHA-256 digest of the token", func(t *testing.T) { + sum := sha256.Sum256([]byte(token)) + + assert.Equal(t, hex.EncodeToString(sum[:]), digest) + }) + + t.Run("never leaves the token inside the digest", func(t *testing.T) { + assert.NotContains(t, digest, token) + }) + + t.Run("produces a digest the table will accept", func(t *testing.T) { + assert.Regexp(t, `^[0-9a-f]{64}$`, digest, + "scim_tokens_token_hash_check refuses anything else") + }) + + t.Run("mints a different token every time", func(t *testing.T) { + other, otherDigest := NewSCIMBearerToken() + + assert.NotEqual(t, token, other) + assert.NotEqual(t, digest, otherDigest) + }) +} diff --git a/internal/models/sso.go b/internal/models/sso.go index 3a5be7d973..869e999159 100644 --- a/internal/models/sso.go +++ b/internal/models/sso.go @@ -222,6 +222,27 @@ func FindSSOProviderByResourceID(tx *storage.Connection, id string) (*SSOProvide return &ssoProvider, nil } +func FindSSOProviderBySCIMToken(tx *storage.Connection, raw string) (*SSOProvider, error) { + var token SCIMToken + err := tx.Eager().Q(). + Where("token_hash = ?", ToSCIMHash(raw)). + Where("revoked_at IS NULL"). + Where("expires_at IS NULL OR expires_at > now()"). + First(&token) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, SSOProviderNotFoundError{} + } + return nil, errors.Wrap(err, "error finding SCIM token") + } + + if !token.SSOProvider.IsEnabled() { + return nil, SSOProviderNotFoundError{} + } + + return token.SSOProvider, nil +} + func FindSSOProviderForEmailAddress(tx *storage.Connection, emailAddress string) (*SSOProvider, error) { parts := strings.Split(emailAddress, "@") emailDomain := strings.ToLower(parts[1]) diff --git a/internal/models/sso_test.go b/internal/models/sso_test.go index 523ad614c7..65453492c9 100644 --- a/internal/models/sso_test.go +++ b/internal/models/sso_test.go @@ -4,6 +4,7 @@ import ( "net/url" "slices" "testing" + "time" "github.com/gofrs/uuid" "github.com/stretchr/testify/require" @@ -469,3 +470,78 @@ func (ts *SSOTestSuite) TestFindSSOProviderByResourceID() { require.Nil(ts.T(), got) } } + +func (ts *SSOTestSuite) TestFindSSOProviderBySCIMToken() { + newProvider := func() *SSOProvider { + id := uuid.Must(uuid.NewV4()).String() + provider := &SSOProvider{ + SAMLProvider: SAMLProvider{ + EntityID: "https://example.com/saml/metadata/" + id, + MetadataXML: "", + }, + SSODomains: []SSODomain{{Domain: id + ".local"}}, + } + require.NoError(ts.T(), ts.db.Eager().Create(provider)) + return provider + } + + ts.Run("resolves the provider for a live token", func() { + provider := newProvider() + token, plaintext := NewSCIMToken(provider) + + require.NoError(ts.T(), ts.db.Create(token)) + + got, err := FindSSOProviderBySCIMToken(ts.db, plaintext) + require.NoError(ts.T(), err) + require.NotNil(ts.T(), got) + require.Equal(ts.T(), provider.ID, got.ID) + }) + + ts.Run("returns not found for an unknown token", func() { + got, err := FindSSOProviderBySCIMToken(ts.db, "never-inserted") + require.ErrorIs(ts.T(), err, SSOProviderNotFoundError{}) + require.Nil(ts.T(), got) + }) + + ts.Run("ignores revoked tokens", func() { + provider := newProvider() + revokedAt := time.Now().Add(-1 * time.Second) + + token, plaintext := NewSCIMToken(provider) + token.CreatedAt = time.Now().Add(-1 * time.Hour) + token.RevokedAt = &revokedAt + require.NoError(ts.T(), ts.db.Create(token)) + + got, err := FindSSOProviderBySCIMToken(ts.db, plaintext) + require.ErrorIs(ts.T(), err, SSOProviderNotFoundError{}) + require.Nil(ts.T(), got) + }) + + ts.Run("ignores expired tokens", func() { + provider := newProvider() + + token, plaintext := NewSCIMToken(provider) + expiredAt := time.Now().Add(-1 * time.Hour) + token.CreatedAt = time.Now().Add(-2 * time.Hour) + token.ExpiresAt = &expiredAt + require.NoError(ts.T(), ts.db.Create(token)) + + got, err := FindSSOProviderBySCIMToken(ts.db, plaintext) + require.ErrorIs(ts.T(), err, SSOProviderNotFoundError{}) + require.Nil(ts.T(), got) + }) + + ts.Run("ignores tokens for disabled providers", func() { + disabled := true + provider := newProvider() + provider.Disabled = &disabled + require.NoError(ts.T(), ts.db.Update(provider)) + + token, plaintext := NewSCIMToken(provider) + require.NoError(ts.T(), ts.db.Create(token)) + + got, err := FindSSOProviderBySCIMToken(ts.db, plaintext) + require.ErrorIs(ts.T(), err, SSOProviderNotFoundError{}) + require.Nil(ts.T(), got) + }) +}