diff --git a/go.mod b/go.mod index b4cc27ddfd..a7b19f8e09 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.4.1-0.20260914212957-04d62eae5f01 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..5838e45c29 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,12 @@ 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.3.1-0.20260910190316-11404e693b82 h1:ykEY+acsy50MeXINz1Q7bwjbVK7C8TnJmSo+3FXPsW0= +github.com/supabase-community/scim-go v0.3.1-0.20260910190316-11404e693b82/go.mod h1:oEMij9JuKtAl0wl0jeyIHDKqHvPpUmTXegKeCBKyxXw= +github.com/supabase-community/scim-go v0.4.1-0.20260914212957-04d62eae5f01 h1:i7wlEsWQ+BkXl91QfmI9U/qh2xdBKJEoRgjp1Z/fOfU= +github.com/supabase-community/scim-go v0.4.1-0.20260914212957-04d62eae5f01/go.mod h1:oEMij9JuKtAl0wl0jeyIHDKqHvPpUmTXegKeCBKyxXw= 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 +511,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..c3a343a5e9 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,17 @@ 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.List) + tenant.Post("/Users", api.scim.Users.Create) + tenant.Get("/Users/{id}", api.scim.Users.ByID) + tenant.Put("/Users/{id}", api.scim.Users.Replace) + tenant.Patch("/Users/{id}", api.scim.Users.Patch) + tenant.Delete("/Users/{id}", api.scim.Users.Delete) }) }) diff --git a/internal/api/scim/auth.go b/internal/api/scim/auth.go new file mode 100644 index 0000000000..710fb093a5 --- /dev/null +++ b/internal/api/scim/auth.go @@ -0,0 +1,54 @@ +package scim + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/supabase/auth/internal/api/shared" + "github.com/supabase/auth/internal/models" +) + +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) 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) { + _ = unauthorized(w) + } else { + _ = 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 +} 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/errors.go b/internal/api/scim/errors.go new file mode 100644 index 0000000000..ff44b5efaa --- /dev/null +++ b/internal/api/scim/errors.go @@ -0,0 +1,38 @@ +package scim + +import ( + "errors" + "net/http" + + "github.com/supabase-community/scim-go/pkg/protocol" + "github.com/supabase-community/scim-go/pkg/scimerrors" + "github.com/supabase/auth/internal/observability" +) + +func NotFound(w http.ResponseWriter, r *http.Request) error { + return protocol.SendError(w, scimerrors.ErrNotFound("Endpoint or resource does not exist")) +} + +func sendError(w http.ResponseWriter, r *http.Request, err error) error { + if scimErr, ok := errors.AsType[*scimerrors.Error](err); ok { + return protocol.SendError(w, scimErr) + } + return internalError(w, r, err) +} + +func internalError(w http.ResponseWriter, r *http.Request, err error) error { + observability.LogEntrySetField(r, "error", err.Error()) + return protocol.SendError(w, scimerrors.ErrInternal("Internal server error")) +} + +func unauthorized(w http.ResponseWriter) error { + w.Header().Set("WWW-Authenticate", `Bearer realm="SCIM"`) + return protocol.SendError(w, scimerrors.ErrUnauthorized("Bearer token is missing or invalid")) +} + +func rejectFilter(w http.ResponseWriter, r *http.Request, unsupported *scimerrors.Error) (bool, error) { + if !r.URL.Query().Has("filter") { + return false, nil + } + return true, protocol.SendError(w, unsupported) +} 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/metadata.go b/internal/api/scim/metadata.go new file mode 100644 index 0000000000..e4b701aa94 --- /dev/null +++ b/internal/api/scim/metadata.go @@ -0,0 +1,82 @@ +package scim + +import ( + "github.com/supabase-community/scim-go/pkg/core" +) + +const userResourceType = "User" + +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: userResourceType, + Name: userResourceType, + Description: schema.Description, + Endpoint: "/Users", + Schema: schema.ID, + Meta: core.Meta{ + ResourceType: "ResourceType", + Location: Join(Join(baseURL, "/ResourceTypes"), userResourceType), + }, + } +} + +func newUserSchema(baseURL string) *core.Schema { + return userSchemaHeader(baseURL). + Describe("User Account"). + With( + core.NewAttribute("userName", core.TypeString, "Unique identifier for the User"). + AsRequired(). + UniqueOn(core.UniquenessServer), + nameAttribute(), + core.NewAttribute("displayName", core.TypeString, "The name of the User, suitable for display."), + emailsAttribute(), + core.NewAttribute("active", core.TypeBoolean, ""), + ) +} + +func userSchemaHeader(baseURL string) *core.Schema { + return &core.Schema{ + Schemas: []core.SchemaURI{core.SchemaSchema}, + ID: core.SchemaUser, + Name: userResourceType, + Meta: core.Meta{ + ResourceType: "Schema", + Location: Join(Join(baseURL, "/Schemas"), string(core.SchemaUser)), + }, + } +} + +func nameAttribute() *core.Attribute { + return 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."), + ) +} + +func emailsAttribute() *core.Attribute { + return 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("type", core.TypeString, "The type of email address."), + core.NewAttribute("primary", core.TypeBoolean, "The 'primary' email address"), + ) +} 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/resource_descriptor.go b/internal/api/scim/resource_descriptor.go new file mode 100644 index 0000000000..9820f540ef --- /dev/null +++ b/internal/api/scim/resource_descriptor.go @@ -0,0 +1,13 @@ +package scim + +import ( + "github.com/supabase-community/scim-go/pkg/core" + "github.com/supabase-community/scim-go/pkg/scimerrors" +) + +type ResourceDescriptor[T core.Resource] struct { + Schema *core.Schema + New func() T + Validate func(T) *scimerrors.Error + Location func(T) string +} diff --git a/internal/api/scim/resource_server.go b/internal/api/scim/resource_server.go new file mode 100644 index 0000000000..7c78bb888e --- /dev/null +++ b/internal/api/scim/resource_server.go @@ -0,0 +1,189 @@ +package scim + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/gofrs/uuid" + "github.com/supabase-community/scim-go/pkg/core" + "github.com/supabase-community/scim-go/pkg/protocol" + "github.com/supabase-community/scim-go/pkg/scimerrors" +) + +type ResourceServer[T core.Resource] struct { + limits protocol.Limits + svc Service[T] + spec ResourceDescriptor[T] +} + +func NewResourceServer[T core.Resource](limits protocol.Limits, svc Service[T], spec ResourceDescriptor[T]) *ResourceServer[T] { + return &ResourceServer[T]{limits: limits, svc: svc, spec: spec} +} + +func (s *ResourceServer[T]) List(w http.ResponseWriter, r *http.Request) error { + query, err := s.limits.ParseSearchRequest(r.URL.Query()) + if err != nil { + return protocol.SendError(w, err) + } + + items, total, err := s.svc.List(r.Context(), query) + if err != nil { + return sendError(w, r, err) + } + + return protocol.Send(w, http.StatusOK, protocol.NewListResponse(query.StartIndex, total, items)) +} + +func (s *ResourceServer[T]) ByID(w http.ResponseWriter, r *http.Request) error { + id, ok := resourceID(r) + if !ok { + return NotFound(w, r) + } + + item, err := s.svc.Get(r.Context(), id) + if err != nil { + return notFoundOr(w, r, err) + } + + return protocol.Send(w, http.StatusOK, item) +} + +func (s *ResourceServer[T]) Create(w http.ResponseWriter, r *http.Request) error { + item, invalid := s.decodeValid(r) + if invalid != nil { + return protocol.SendError(w, invalid) + } + + created, err := s.svc.Create(r.Context(), item) + if err != nil { + return sendError(w, r, err) + } + + w.Header().Set("Location", s.spec.Location(created)) + return protocol.Send(w, http.StatusCreated, created) +} + +func (s *ResourceServer[T]) Replace(w http.ResponseWriter, r *http.Request) error { + id, ok := resourceID(r) + if !ok { + return NotFound(w, r) + } + + item, invalid := s.decodeValid(r) + if invalid != nil { + return protocol.SendError(w, invalid) + } + replaced, err := s.svc.Replace(r.Context(), id, item) + if err != nil { + return notFoundOr(w, r, err) + } + return protocol.Send(w, http.StatusOK, replaced) +} + +func (s *ResourceServer[T]) Patch(w http.ResponseWriter, r *http.Request) error { + id, ok := resourceID(r) + if !ok { + return NotFound(w, r) + } + + req, invalid := s.decodePatch(r) + if invalid != nil { + return protocol.SendError(w, invalid) + } + + current, err := s.svc.Get(r.Context(), id) + if err != nil { + return notFoundOr(w, r, err) + } + if err := req.Apply(current, []*core.Schema{s.spec.Schema}); err != nil { + return sendError(w, r, err) + } + if invalid := s.spec.Validate(current); invalid != nil { + return protocol.SendError(w, invalid) + } + + replaced, err := s.svc.Replace(r.Context(), id, current) + if err != nil { + return notFoundOr(w, r, err) + } + return protocol.Send(w, http.StatusOK, replaced) +} + +func (s *ResourceServer[T]) Delete(w http.ResponseWriter, r *http.Request) error { + id, ok := resourceID(r) + if !ok { + return NotFound(w, r) + } + + if err := s.svc.Delete(r.Context(), id); err != nil { + return notFoundOr(w, r, err) + } + + return protocol.Send(w, http.StatusNoContent, nil) +} + +func (s *ResourceServer[T]) decodeValid(r *http.Request) (T, *scimerrors.Error) { + item, err := s.decode(r) + if err != nil { + return item, err + } + return item, s.spec.Validate(item) +} + +func (s *ResourceServer[T]) decode(r *http.Request) (T, *scimerrors.Error) { + item := s.spec.New() + + body, invalid := readBody(r) + if invalid != nil { + return item, invalid + } + + if err := json.Unmarshal(body, item); err != nil { + return item, scimerrors.ErrInvalidSyntax("request body is not a valid " + string(s.spec.Schema.Name)) + } + return item, nil +} + +func (s *ResourceServer[T]) decodePatch(r *http.Request) (*protocol.PatchRequest, *scimerrors.Error) { + body, invalid := readBody(r) + if invalid != nil { + return nil, invalid + } + + req := new(protocol.PatchRequest) + if err := json.Unmarshal(body, req); err != nil { + return nil, scimerrors.ErrInvalidSyntax("request body is not a valid PatchOp") + } + if len(req.Operations) == 0 { + return nil, scimerrors.ErrInvalidValue(`"Operations" must contain at least one operation`) + } + return req, nil +} + +func readBody(r *http.Request) ([]byte, *scimerrors.Error) { + body, err := io.ReadAll(r.Body) + if err != nil { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + return nil, scimerrors.ErrTooLarge("the request body is too large") + } + return nil, scimerrors.ErrInvalidSyntax("could not read the request body") + } + return body, nil +} + +func resourceID(r *http.Request) (string, bool) { + id, err := uuid.FromString(urlParam(r, "id")) + if err != nil { + return "", false + } + return id.String(), true +} + +func notFoundOr(w http.ResponseWriter, r *http.Request, err error) error { + if errors.Is(err, ErrNotFound) { + return NotFound(w, r) + } + return sendError(w, r, err) +} diff --git a/internal/api/scim/resource_server_test.go b/internal/api/scim/resource_server_test.go new file mode 100644 index 0000000000..bbb537e943 --- /dev/null +++ b/internal/api/scim/resource_server_test.go @@ -0,0 +1,133 @@ +package scim + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "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" + "github.com/supabase-community/scim-go/pkg/scimerrors" +) + +type fakeUserService struct { + getResult *core.User + getErr error + createResult *core.User + createErr error + createCalled bool + listItems []*core.User + listTotal int + listErr error +} + +func (f *fakeUserService) Get(ctx context.Context, id string) (*core.User, error) { + return f.getResult, f.getErr +} + +func (f *fakeUserService) List(ctx context.Context, query *protocol.SearchRequest) ([]*core.User, int, error) { + return f.listItems, f.listTotal, f.listErr +} + +func (f *fakeUserService) Create(ctx context.Context, item *core.User) (*core.User, error) { + f.createCalled = true + if f.createResult != nil { + return f.createResult, f.createErr + } + return item, f.createErr +} + +func (f *fakeUserService) Replace(ctx context.Context, id string, item *core.User) (*core.User, error) { + return item, nil +} + +func (f *fakeUserService) Delete(ctx context.Context, id string) error { + return nil +} + +func newFakeServer(svc Service[*core.User]) *ResourceServer[*core.User] { + return NewResourceServer(protocol.DefaultLimits, svc, ResourceDescriptor[*core.User]{ + Schema: newUserSchema(testExternalURL + BasePath), + New: func() *core.User { return new(core.User) }, + Validate: validateUser, + Location: func(u *core.User) string { return u.Meta.Location }, + }) +} + +func TestResourceServer(t *testing.T) { + t.Run("ByID", func(t *testing.T) { + t.Run("returns 404 when the id is not a uuid", func(t *testing.T) { + svc := &fakeUserService{getResult: &core.User{}} + r := requestWithURLParam("/Users/not-a-uuid", "id", "not-a-uuid") + w := httptest.NewRecorder() + + require.NoError(t, newFakeServer(svc).ByID(w, r)) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + }) + + t.Run("returns 404 when the service reports the resource is missing", func(t *testing.T) { + svc := &fakeUserService{getErr: ErrNotFound} + id := uuid.Must(uuid.NewV4()).String() + r := requestWithURLParam("/Users/"+id, "id", id) + w := httptest.NewRecorder() + + require.NoError(t, newFakeServer(svc).ByID(w, r)) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + }) + + t.Run("Create", func(t *testing.T) { + t.Run("returns the validation error without calling the service", func(t *testing.T) { + svc := &fakeUserService{} + body := `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"]}` + r := httptest.NewRequest(http.MethodPost, BasePath+"/Users", strings.NewReader(body)) + w := httptest.NewRecorder() + + require.NoError(t, newFakeServer(svc).Create(w, r)) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), string(scimerrors.InvalidValue)) + assert.False(t, svc.createCalled) + }) + + t.Run("sets the Location header from the created resource", func(t *testing.T) { + location := testExternalURL + BasePath + "/Users/abc" + svc := &fakeUserService{createResult: &core.User{Meta: core.Meta{Location: location}}} + body := `{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],"userName":"bjensen"}` + r := httptest.NewRequest(http.MethodPost, BasePath+"/Users", strings.NewReader(body)) + w := httptest.NewRecorder() + + require.NoError(t, newFakeServer(svc).Create(w, r)) + + assert.Equal(t, http.StatusCreated, w.Code) + assert.Equal(t, location, w.Header().Get("Location")) + assert.True(t, svc.createCalled) + }) + }) + + t.Run("List", func(t *testing.T) { + t.Run("reports the total the service returns", func(t *testing.T) { + svc := &fakeUserService{ + listItems: []*core.User{{UserName: "a"}, {UserName: "b"}}, + listTotal: 7, + } + r := httptest.NewRequest(http.MethodGet, BasePath+"/Users", nil) + w := httptest.NewRecorder() + + require.NoError(t, newFakeServer(svc).List(w, r)) + + require.Equal(t, http.StatusOK, w.Code) + body := listed[*core.User](t, w) + assert.Equal(t, 7, body.TotalResults) + assert.Len(t, body.Resources, 2) + }) + }) +} diff --git a/internal/api/scim/scim_user.go b/internal/api/scim/scim_user.go new file mode 100644 index 0000000000..c7bcd674d8 --- /dev/null +++ b/internal/api/scim/scim_user.go @@ -0,0 +1,15 @@ +package scim + +import "time" + +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" +} diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go index 4de38e4ce3..0a67592959 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -2,47 +2,118 @@ package scim import ( "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/supabase-community/scim-go/pkg/core" + "github.com/supabase-community/scim-go/pkg/protocol" + "github.com/supabase-community/scim-go/pkg/scimerrors" + "github.com/supabase/auth/internal/storage" ) const BasePath = "/scim/v2" type Server struct { + db *storage.Connection + Users *ResourceServer[*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, + Users: newUserResourceServer(db, baseURL, userSchema, protocol.DefaultLimits), + serviceProviderConfig: newServiceProviderConfig( + baseURL, core.NewOAuthBearerToken().AsPrimary(), - ), + ).Sorting().Filtering(protocol.DefaultLimits.MaxCount).Patching(), + resourceTypes: []*core.ResourceType{newUserResourceType(baseURL, userSchema)}, + schemas: []*core.Schema{userSchema}, } } +func newUserResourceServer(db *storage.Connection, baseURL string, schema *core.Schema, limits protocol.Limits) *ResourceServer[*core.User] { + return NewResourceServer( + limits, + NewUserService(&userRepository{db: db, baseURL: baseURL, schema: schema}), + ResourceDescriptor[*core.User]{ + Schema: schema, + New: func() *core.User { return new(core.User) }, + Validate: validateUser, + Location: func(u *core.User) string { return u.Meta.Location }, + }, + ) +} + +func Join(base, segment string) string { + return strings.TrimSuffix(base, "/") + "/" + strings.TrimPrefix(segment, "/") +} + +func validateUser(user *core.User) *scimerrors.Error { + if user.UserName == "" { + return scimerrors.ErrInvalidValue(`"userName" is required`) + } + if !slices.Contains(user.Schemas, core.SchemaUser) { + return scimerrors.ErrInvalidValue(`"schemas" must include the User schema URN`) + } + return nil +} + 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) NotFound(w http.ResponseWriter, r *http.Request) error { - return protocol.SendError(w, http.StatusNotFound, "", "Endpoint or resource does not exist") + return NotFound(w, r) +} + +func (srv *Server) list[T any](w http.ResponseWriter, r *http.Request, resources []T) error { + if rejected, err := rejectFilter(w, r, scimerrors.ErrForbidden("Filtering is not supported on this endpoint")); rejected { + return err + } + + return protocol.Send(w, http.StatusOK, protocol.NewListResponse(1, len(resources), resources)) } -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) 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 NotFound(w, r) +} + +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..5b984ab962 100644 --- a/internal/api/scim/server_test.go +++ b/internal/api/scim/server_test.go @@ -1,15 +1,26 @@ 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-community/scim-go/pkg/scimerrors" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" ) //go:embed testdata/* @@ -21,24 +32,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("returns 401 with a challenge when the token is unknown", func(t *testing.T) { + unknown, _ := models.NewSCIMBearerToken() + + 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("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 when there is no Authorization header", func(t *testing.T) { + w, seen := served(t, "") - require.Equal(t, "https://auth.example.com"+BasePath+"/ServiceProviderConfig", location) + 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 +115,580 @@ 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, srv.Users.List(w, r)) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + + var body scimerrors.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, scimerrors.InvalidValue, 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, tc.handler(w, r)) + require.NoError(t, srv.Users.List(w, r)) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + + var body scimerrors.Error + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, scimerrors.InvalidValue, 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(scimerrors.InvalidValue)) + 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(scimerrors.InvalidValue)) + }) + + t.Run("?filter narrows the result set", func(t *testing.T) { + get := usersFor(t, srv, db, "alice", "bob", "carol") + + body := listed[*core.User](t, get(filterQuery(`userName eq "bob"`))) + + assert.Equal(t, 1, body.TotalResults) + assert.Equal(t, []string{"bob"}, userNamesOf(body.Resources)) + }) + + t.Run("?filter co matches substrings case-insensitively", func(t *testing.T) { + get := usersFor(t, srv, db, "alice", "bob", "carol") + + body := listed[*core.User](t, get(filterQuery(`userName co "A"`))) + + assert.ElementsMatch(t, []string{"alice", "carol"}, userNamesOf(body.Resources)) + }) + + t.Run("?filter rejects an operator invalid for the attribute type", func(t *testing.T) { + get := usersFor(t, srv, db, "alice") + + w := get(filterQuery(`active gt true`)) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + assert.Contains(t, w.Body.String(), string(scimerrors.InvalidFilter)) + }) + + t.Run("?filter matches a value path against emails", func(t *testing.T) { + tenant := createTenant(t, db) + createUser(t, db, tenant, &core.User{ + ID: uuid.Must(uuid.NewV4()).String(), + UserName: "worker", + Emails: []core.Email{{Value: "worker@example.com", Type: "work"}}, + }) + createUser(t, db, tenant, &core.User{ + ID: uuid.Must(uuid.NewV4()).String(), + UserName: "homebody", + Emails: []core.Email{{Value: "home@example.com", Type: "home"}}, + }) + + r := httptest.NewRequest(http.MethodGet, BasePath+"/Users?"+filterQuery(`emails[type eq "work"]`), nil) + r = r.WithContext(tenantKey.WithValue(r.Context(), tenant)) + w := httptest.NewRecorder() + require.NoError(t, srv.Users.List(w, r)) + + body := listed[*core.User](t, w) + assert.Equal(t, []string{"worker"}, userNamesOf(body.Resources)) + }) + }) + + 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.Users.Create(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.Users.Create(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.Users.Create(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.Users.Create(w, r)) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), string(scimerrors.InvalidValue)) + }) + + 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.Users.Create(w, r)) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), string(scimerrors.InvalidValue)) + }) + + 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.Users.Create(w, r)) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), string(scimerrors.InvalidSyntax)) + }) + + 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.Users.Create(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(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("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.Users.Replace(w, r)) - require.NoError(t, tc.handler(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.Users.Replace(w, r)) - require.Equal(t, http.StatusForbidden, w.Code) - require.JSONEq(t, testFixture(t, "filter_forbidden.json"), w.Body.String()) + assert.Equal(t, http.StatusNotFound, w.Code) + }) + }) + + t.Run("PATCH /Users/{id}", func(t *testing.T) { + patch := func(t *testing.T, tenant *Tenant, id, body string) *httptest.ResponseRecorder { + t.Helper() + r := scimRequest(http.MethodPatch, "/Users/"+id, body, tenant, map[string]string{"id": id}) + w := httptest.NewRecorder() + require.NoError(t, srv.Users.Patch(w, r)) + return w + } + + fetch := func(t *testing.T, tenant *Tenant, id string) *core.User { + t.Helper() + r := scimRequest(http.MethodGet, "/Users/"+id, "", tenant, map[string]string{"id": id}) + w := httptest.NewRecorder() + require.NoError(t, srv.Users.ByID(w, r)) + require.Equal(t, http.StatusOK, w.Code) + + var user core.User + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &user)) + return &user + } + + t.Run("replaces an attribute named by a path", func(t *testing.T) { + tenant := createTenant(t, db) + created := create(t, srv, tenant, "patch-replace") + + body := `{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","path":"displayName","value":"Babs Jensen"}]}` + w := patch(t, tenant, created.ID, body) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + + var returned core.User + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &returned)) + assert.Equal(t, "Babs Jensen", returned.DisplayName) + assert.Equal(t, "Babs Jensen", fetch(t, tenant, created.ID).DisplayName) + }) + + t.Run("adds members to a multi-valued attribute", func(t *testing.T) { + tenant := createTenant(t, db) + created := create(t, srv, tenant, "patch-add") + + body := `{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"add","path":"emails","value":[{"value":"work@example.com","type":"work"}]}]}` + w := patch(t, tenant, created.ID, body) + + require.Equal(t, http.StatusOK, w.Code) + persisted := fetch(t, tenant, created.ID) + require.Len(t, persisted.Emails, 1) + assert.Equal(t, "work@example.com", persisted.Emails[0].Value) + assert.Equal(t, "work", persisted.Emails[0].Type) + }) + + t.Run("removes an attribute named by a path", func(t *testing.T) { + tenant := createTenant(t, db) + created := create(t, srv, tenant, "patch-remove") + seed := `{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","path":"displayName","value":"To Be Removed"}]}` + require.Equal(t, http.StatusOK, patch(t, tenant, created.ID, seed).Code) + + body := `{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"remove","path":"displayName"}]}` + w := patch(t, tenant, created.ID, body) + + require.Equal(t, http.StatusOK, w.Code) + assert.Empty(t, fetch(t, tenant, created.ID).DisplayName) + }) + + t.Run("merges an operation without a path", func(t *testing.T) { + tenant := createTenant(t, db) + created := create(t, srv, tenant, "patch-merge") + + body := `{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","value":{"displayName":"Merged Name"}}]}` + w := patch(t, tenant, created.ID, body) + + require.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "Merged Name", fetch(t, tenant, created.ID).DisplayName) + }) + + t.Run("applies a value-path filter to a multi-valued attribute", func(t *testing.T) { + tenant := createTenant(t, db) + created := create(t, srv, tenant, "patch-valuepath") + seed := `{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"add","path":"emails","value":[{"value":"old@example.com","type":"work"}]}]}` + require.Equal(t, http.StatusOK, patch(t, tenant, created.ID, seed).Code) + + body := `{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","path":"emails[type eq \"work\"].value","value":"new@example.com"}]}` + w := patch(t, tenant, created.ID, body) + + require.Equal(t, http.StatusOK, w.Code) + persisted := fetch(t, tenant, created.ID) + require.Len(t, persisted.Emails, 1) + assert.Equal(t, "new@example.com", persisted.Emails[0].Value) + }) + + 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:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","path":"displayName","value":"ghost"}]}` + w := patch(t, tenant, id, body) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("rejects an empty operations list", func(t *testing.T) { + tenant := createTenant(t, db) + created := create(t, srv, tenant, "patch-empty") + + body := `{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[]}` + w := patch(t, tenant, created.ID, body) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), string(scimerrors.InvalidValue)) + }) + + t.Run("rejects a malformed body", func(t *testing.T) { + tenant := createTenant(t, db) + created := create(t, srv, tenant, "patch-malformed") + + w := patch(t, tenant, created.ID, `{"Operations":`) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), string(scimerrors.InvalidSyntax)) + }) + + t.Run("rejects an unknown path", func(t *testing.T) { + tenant := createTenant(t, db) + created := create(t, srv, tenant, "patch-badpath") + + body := `{"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],"Operations":[{"op":"replace","path":"nickName","value":"nope"}]}` + w := patch(t, tenant, created.ID, body) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), string(scimerrors.InvalidPath)) + }) + }) + + 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.Users.Delete(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.Users.ByID(gw, get)) + assert.Equal(t, http.StatusNotFound, gw.Code) + }) + + 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.Users.Delete(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() +} + +func scimRequest(method, target, body string, tenant *Tenant, params map[string]string) *http.Request { + r := httptest.NewRequest(method, BasePath+target, strings.NewReader(body)) + + 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.List(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/service.go b/internal/api/scim/service.go new file mode 100644 index 0000000000..8822f63cbe --- /dev/null +++ b/internal/api/scim/service.go @@ -0,0 +1,16 @@ +package scim + +import ( + "context" + + "github.com/supabase-community/scim-go/pkg/protocol" +) + +// Separate from Repository[T] so SAML/SCIM identity-linking sync can sit here later without touching storage. +type Service[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/sql.go b/internal/api/scim/sql.go new file mode 100644 index 0000000000..d6e705fb84 --- /dev/null +++ b/internal/api/scim/sql.go @@ -0,0 +1,145 @@ +package scim + +import ( + "fmt" + + "github.com/gofrs/uuid" + "github.com/supabase-community/scim-go/pkg/core" + "github.com/supabase-community/scim-go/pkg/filter" + "github.com/supabase-community/scim-go/pkg/scimerrors" +) + +type sqlEvaluator struct { + element string +} + +func (f *sqlEvaluator) Compare(attribute *core.Attribute, key string, op filter.Operator, value any) (sqlFragment, error) { + if frag, matched, err := f.likeCompare(attribute, key, op, value); matched { + return frag, err + } + if err := f.requireUUID(key, value); err != nil { + return sqlFragment{}, err + } + return f.compareOperand(attribute, key, op, value) +} + +func (f *sqlEvaluator) likeCompare(attribute *core.Attribute, key string, op filter.Operator, value any) (sqlFragment, bool, error) { + if _, ok := likePatterns[op]; !ok { + return sqlFragment{}, false, nil + } + frag, err := f.like(attribute, key, op, value) + return frag, true, err +} + +func (f *sqlEvaluator) compareOperand(attribute *core.Attribute, key string, op filter.Operator, value any) (sqlFragment, error) { + column, placeholder := f.operand(attribute, key) + if op == filter.OpNotEquals { + return sqlFragment{sql: column + " IS DISTINCT FROM " + placeholder, args: []any{value}}, nil + } + symbol, ok := comparators[op] + if !ok { + return sqlFragment{}, scimerrors.ErrInvalidFilter(fmt.Sprintf("operator %q is not supported", op)) + } + return sqlFragment{sql: column + " " + symbol + " " + placeholder, args: []any{value}}, nil +} + +func (f *sqlEvaluator) Present(attribute *core.Attribute, key string) (sqlFragment, error) { + column, _ := f.resolveColumn(attribute, key) + return sqlFragment{sql: column + " IS NOT NULL"}, nil +} + +func (f *sqlEvaluator) And(left, right sqlFragment) (sqlFragment, error) { + return combine(left, right, "AND"), nil +} + +func (f *sqlEvaluator) Or(left, right sqlFragment) (sqlFragment, error) { + return combine(left, right, "OR"), nil +} + +func (f *sqlEvaluator) Not(operand sqlFragment) (sqlFragment, error) { + return sqlFragment{sql: "(" + operand.sql + ") IS NOT TRUE", args: operand.args}, nil +} + +func (f *sqlEvaluator) ValuePath(attribute *core.Attribute, key string, valueFilter func() (sqlFragment, error)) (sqlFragment, error) { + previous := f.element + f.element = "e" + inner, err := valueFilter() + f.element = previous + if err != nil { + return sqlFragment{}, err + } + + source := "resource->'" + attribute.Name + "'" + sql := "EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(" + source + ", '[]'::jsonb)) AS e WHERE " + inner.sql + ")" + return sqlFragment{sql: sql, args: inner.args}, nil +} + +func (f *sqlEvaluator) columnExpr(attribute *core.Attribute, key string) (expr string, promoted, lowered, isUUID bool) { + column, promoted := f.resolveColumn(attribute, key) + if promoted && uuidColumns[column] { + return column, promoted, false, true + } + if attribute.Type == core.TypeString && !attribute.CaseExact { + return loweredExpr(column), promoted, true, false + } + return column, promoted, false, false +} + +func (f *sqlEvaluator) operand(attribute *core.Attribute, key string) (string, string) { + expr, promoted, lowered, isUUID := f.columnExpr(attribute, key) + if isUUID { + return expr, "?" + } + if lowered { + return expr, "lower(?)" + } + if cast, ok := castExpr(expr, promoted, attribute.Type); ok { + return cast, "?" + } + return expr, "?" +} + +func (f *sqlEvaluator) like(attribute *core.Attribute, key string, op filter.Operator, value any) (sqlFragment, error) { + if attribute.Type != core.TypeString { + return sqlFragment{}, scimerrors.ErrInvalidFilter(fmt.Sprintf("operator %q is not supported for a %s attribute", op, attribute.Type)) + } + text, ok := value.(string) + if !ok { + return sqlFragment{}, scimerrors.ErrInvalidValue("a string value is required") + } + arg := fmt.Sprintf(likePatterns[op], escapeLike(text)) + return f.likeFragment(attribute, key, arg), nil +} + +func (f *sqlEvaluator) likeFragment(attribute *core.Attribute, key string, arg string) sqlFragment { + expr, _, lowered, isUUID := f.columnExpr(attribute, key) + if isUUID { + return sqlFragment{sql: "lower(" + expr + "::text) LIKE lower(?) ESCAPE '\\'", args: []any{arg}} + } + if lowered { + return sqlFragment{sql: expr + " LIKE lower(?) ESCAPE '\\'", args: []any{arg}} + } + return sqlFragment{sql: expr + " LIKE ? ESCAPE '\\'", args: []any{arg}} +} + +func (f *sqlEvaluator) requireUUID(key string, value any) error { + if !f.isUUIDColumn(key) { + return nil + } + text, ok := value.(string) + if !ok { + return scimerrors.ErrInvalidValue("a string value is required") + } + if _, err := uuid.FromString(text); err != nil { + return scimerrors.ErrInvalidValue("a valid uuid value is required") + } + return nil +} + +func (f *sqlEvaluator) isUUIDColumn(key string) bool { + if f.element != "" { + return false + } + column, ok := filterColumns[key] + return ok && uuidColumns[column] +} diff --git a/internal/api/scim/sql_columns.go b/internal/api/scim/sql_columns.go new file mode 100644 index 0000000000..aebced697a --- /dev/null +++ b/internal/api/scim/sql_columns.go @@ -0,0 +1,84 @@ +package scim + +import ( + "strings" + + "github.com/supabase-community/scim-go/pkg/core" + "github.com/supabase-community/scim-go/pkg/filter" +) + +var loweredColumns = map[string]bool{ + "user_name": true, +} + +var uuidColumns = map[string]bool{ + "id": true, +} + +var comparators = map[filter.Operator]string{ + filter.OpEquals: "=", + filter.OpGreaterThan: ">", + filter.OpGreaterThanEquals: ">=", + filter.OpLessThan: "<", + filter.OpLessThanEquals: "<=", +} + +var likePatterns = map[filter.Operator]string{ + filter.OpContains: "%%%s%%", + filter.OpStartsWith: "%s%%", + filter.OpEndsWith: "%%%s", +} + +func (f *sqlEvaluator) resolveColumn(attribute *core.Attribute, key string) (string, bool) { + if f.element != "" { + return f.element + "->>'" + attribute.Name + "'", false + } + if column, ok := filterColumns[key]; ok { + return column, true + } + return "resource->>'" + attribute.Name + "'", false +} + +func escapeLike(s string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) +} + +func castFor(attributeType core.AttributeType) string { + switch attributeType { + case core.TypeDateTime: + return "::timestamptz" + case core.TypeInteger, core.TypeDecimal: + return "::numeric" + case core.TypeBoolean: + return "::boolean" + } + return "" +} + +func loweredExpr(column string) string { + if loweredColumns[column] { + return column + } + return "lower(" + column + ")" +} + +func castExpr(column string, promoted bool, attributeType core.AttributeType) (string, bool) { + if promoted { + return "", false + } + cast := castFor(attributeType) + if cast == "" { + return "", false + } + return "(" + column + ")" + cast, true +} + +func combine(left, right sqlFragment, op string) sqlFragment { + args := make([]any, 0, len(left.args)+len(right.args)) + args = append(args, left.args...) + args = append(args, right.args...) + return sqlFragment{ + sql: "(" + left.sql + " " + op + " " + right.sql + ")", + args: args, + } +} diff --git a/internal/api/scim/sql_fragment.go b/internal/api/scim/sql_fragment.go new file mode 100644 index 0000000000..ef68211915 --- /dev/null +++ b/internal/api/scim/sql_fragment.go @@ -0,0 +1,6 @@ +package scim + +type sqlFragment struct { + sql string + args []any +} 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..c245eae7d2 --- /dev/null +++ b/internal/api/scim/testdata/schema_user.json @@ -0,0 +1,150 @@ +{ + "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": "displayName", + "type": "string", + "multiValued": false, + "description": "The name of the User, suitable for display.", + "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": "type", + "type": "string", + "multiValued": false, + "description": "The type of email address.", + "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..7052376046 --- /dev/null +++ b/internal/api/scim/testdata/schemas.json @@ -0,0 +1,160 @@ +{ + "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": "displayName", + "type": "string", + "multiValued": false, + "description": "The name of the User, suitable for display.", + "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": "type", + "type": "string", + "multiValued": false, + "description": "The type of email address.", + "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..f091553a51 100644 --- a/internal/api/scim/testdata/service_provider_config.json +++ b/internal/api/scim/testdata/service_provider_config.json @@ -3,7 +3,7 @@ "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" ], "patch": { - "supported": false + "supported": true }, "bulk": { "supported": false, @@ -11,14 +11,14 @@ "maxPayloadSize": 0 }, "filter": { - "supported": false, - "maxResults": 0 + "supported": true, + "maxResults": 100 }, "changePassword": { "supported": false }, "sort": { - "supported": false + "supported": true }, "etag": { "supported": false diff --git a/internal/api/scim/user_queries.go b/internal/api/scim/user_queries.go new file mode 100644 index 0000000000..16cd7a1b82 --- /dev/null +++ b/internal/api/scim/user_queries.go @@ -0,0 +1,110 @@ +package scim + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/supabase-community/scim-go/pkg/core" + "github.com/supabase-community/scim-go/pkg/protocol" + "github.com/supabase-community/scim-go/pkg/scimerrors" +) + +type userQuery struct { + filterSQL string + orderBy string + args []any +} + +var filterColumns = map[string]string{ + "id": "id", + "username": "user_name", + "externalid": "external_id", + "active": "active", + "meta.created": "created_at", + "meta.lastmodified": "updated_at", +} + +var userSortColumns = buildSortColumns() + +func buildSortColumns() map[string]string { + sortable := []string{"id", "username", "meta.created", "meta.lastmodified"} + columns := make(map[string]string, len(sortable)) + for _, key := range sortable { + column := filterColumns[key] + if loweredColumns[column] { + column = `lower(` + column + ` collate "C")` + } + columns[key] = column + } + return columns +} + +const countUsers = `SELECT COUNT(*) FROM scim_users WHERE sso_provider_id = ? AND deleted_at IS NULL%s` + +const listUsers = `SELECT id, resource, active, created_at, updated_at FROM scim_users WHERE sso_provider_id = ? AND deleted_at IS NULL%s ORDER BY %s LIMIT ? OFFSET ?` + +func (r *userRepository) filterClause(query *protocol.SearchRequest) (string, []any, error) { + if query.Filter == "" { + return "", nil, nil + } + + fragment, err := protocol.Filter[sqlFragment]([]*core.Schema{r.schema}, query.Filter, &sqlEvaluator{}) + if err != nil { + return "", nil, err + } + return " AND (" + fragment.sql + ")", fragment.args, nil +} + +func (r *userRepository) orderBy(query *protocol.SearchRequest) (string, error) { + column := "id" + if query.SortBy != "" { + sortable, ok := userSortColumns[strings.ToLower(query.SortBy)] + if !ok { + return "", scimerrors.ErrInvalidValue(strconv.Quote(query.SortBy) + " is not an attribute this resource can be sorted by") + } + column = sortable + } + + direction := sortDirection(query) + if column == "id" { + return column + direction, nil + } + return column + direction + ", id" + direction, nil +} + +func sortDirection(query *protocol.SearchRequest) string { + if query.Descending() { + return " DESC" + } + return " ASC" +} + +func (r *userRepository) count(ctx context.Context, q userQuery) (int, error) { + db, tenant := r.db.WithContext(ctx), r.tenant(ctx) + countArgs := append([]any{tenant}, q.args...) + var total int + if err := db.RawQuery(fmt.Sprintf(countUsers, q.filterSQL), countArgs...).First(&total); err != nil { + return 0, fmt.Errorf("scim: counting users: %w", err) + } + return total, nil +} + +func (r *userRepository) page(ctx context.Context, q userQuery, query *protocol.SearchRequest) ([]*core.User, error) { + db, tenant := r.db.WithContext(ctx), r.tenant(ctx) + listArgs := append(append([]any{tenant}, q.args...), query.Count, query.Offset()) + var rows []scimUser + if err := db.RawQuery(fmt.Sprintf(listUsers, q.filterSQL, q.orderBy), listArgs...).All(&rows); err != nil { + return nil, 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, err + } + users = append(users, user) + } + return users, nil +} diff --git a/internal/api/scim/user_repository.go b/internal/api/scim/user_repository.go new file mode 100644 index 0000000000..f0f898c147 --- /dev/null +++ b/internal/api/scim/user_repository.go @@ -0,0 +1,148 @@ +package scim + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "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-community/scim-go/pkg/scimerrors" + "github.com/supabase/auth/internal/storage" +) + +var ErrNotFound = errors.New("scim: resource not found") + +type userRepository struct { + db *storage.Connection + baseURL string + schema *core.Schema +} + +func NewUserRepository(db *storage.Connection, baseURL string) Repository[*core.User] { + return &userRepository{ + db: db, + baseURL: baseURL, + schema: newUserSchema(baseURL), + } +} + +func (r *userRepository) List(ctx context.Context, query *protocol.SearchRequest) ([]*core.User, int, error) { + orderBy, err := r.orderBy(query) + if err != nil { + return nil, 0, err + } + filterSQL, filterArgs, err := r.filterClause(query) + if err != nil { + return nil, 0, err + } + q := userQuery{filterSQL: filterSQL, orderBy: orderBy, args: filterArgs} + total, err := r.count(ctx, q) + if err != nil || query.Count <= 0 { + return nil, total, err + } + users, err := r.page(ctx, q, query) + return users, total, err +} + +func (r *userRepository) Get(ctx context.Context, id string) (*core.User, error) { + var rows []scimUser + if 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); 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 { + if pgErr.ConstraintName == "scim_users_external_id_key" { + return scimerrors.ErrUniqueness("a User with this externalId already exists") + } + return scimerrors.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 = r.metaFor(row) + if len(user.Schemas) == 0 { + user.Schemas = []core.SchemaURI{core.SchemaUser} + } + return user, nil +} + +func (r *userRepository) metaFor(row *scimUser) core.Meta { + return core.Meta{ + ResourceType: userResourceType, + Created: row.CreatedAt.UTC(), + LastModified: row.UpdatedAt.UTC(), + Location: Join(Join(r.baseURL, "/Users"), row.ID), + } +} + +func (r *userRepository) tenant(ctx context.Context) string { + return tenantKey.Value(ctx).ID.String() +} diff --git a/internal/api/scim/user_repository_test.go b/internal/api/scim/user_repository_test.go new file mode 100644 index 0000000000..2458d65f6a --- /dev/null +++ b/internal/api/scim/user_repository_test.go @@ -0,0 +1,441 @@ +package scim + +import ( + "fmt" + "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" + "github.com/supabase-community/scim-go/pkg/scimerrors" +) + +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) { + 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, scimerrors.ErrInvalidValue("")) + assert.Contains(t, err.Error(), "nickName") + }) + + t.Run("filters by an exact userName", func(t *testing.T) { + users, total, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `userName eq "alice@example.com"`}) + require.NoError(t, err) + assert.Equal(t, 1, total) + assert.Equal(t, []string{"alice@example.com"}, userNamesOf(users)) + }) + + t.Run("matches a userName case-insensitively against the lowered column", func(t *testing.T) { + users, total, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `userName eq "bjensen@example.com"`}) + require.NoError(t, err) + assert.Equal(t, 1, total) + assert.Equal(t, []string{"BJensen@example.com"}, userNamesOf(users)) + }) + + t.Run("filters case-insensitively with co", func(t *testing.T) { + users, _, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `userName co "JENSEN"`}) + require.NoError(t, err) + assert.Equal(t, []string{"BJensen@example.com"}, userNamesOf(users)) + }) + + t.Run("filters with co on the lowered column", func(t *testing.T) { + users, _, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `userName co "jen"`}) + require.NoError(t, err) + assert.Equal(t, []string{"BJensen@example.com"}, userNamesOf(users)) + }) + + t.Run("filters with ne, excluding the match", func(t *testing.T) { + users, total, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `userName ne "bjensen@example.com"`}) + require.NoError(t, err) + assert.Equal(t, count-1, total) + assert.NotContains(t, userNamesOf(users), "BJensen@example.com") + }) + + t.Run("filters active users", func(t *testing.T) { + users, total, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `active eq true`}) + require.NoError(t, err) + assert.Equal(t, count-1, total) + assert.NotContains(t, userNamesOf(users), seedInactiveUserName) + }) + + t.Run("filters with sw on a jsonb attribute", func(t *testing.T) { + users, _, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `displayName sw "Dr"`}) + require.NoError(t, err) + assert.Equal(t, []string{seedDisplayUserName}, userNamesOf(users)) + }) + + t.Run("filters a value path against array elements", func(t *testing.T) { + users, _, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `emails[type eq "work"]`}) + require.NoError(t, err) + assert.Equal(t, []string{seedWorkEmailUserName}, userNamesOf(users)) + }) + + t.Run("filters a value path composing an inner and", func(t *testing.T) { + users, _, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `emails[type eq "work" and value co "example.com"]`}) + require.NoError(t, err) + assert.Equal(t, []string{seedWorkEmailUserName}, userNamesOf(users)) + }) + + t.Run("composes a top-level and", func(t *testing.T) { + users, total, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `userName eq "bob@example.com" and active eq true`}) + require.NoError(t, err) + assert.Equal(t, 1, total) + assert.Equal(t, []string{"bob@example.com"}, userNamesOf(users)) + }) + + t.Run("escapes LIKE metacharacters in co", func(t *testing.T) { + users, _, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `userName co "a_b%c\\d"`}) + require.NoError(t, err) + assert.Equal(t, []string{seedLikeUserName}, userNamesOf(users)) + }) + + t.Run("negates null-safely with not", func(t *testing.T) { + users, total, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `not (displayName eq "x")`}) + require.NoError(t, err) + assert.Equal(t, count-1, total) + + names := userNamesOf(users) + assert.NotContains(t, names, seedDisplayXUserName) + assert.Contains(t, names, "alice@example.com") + }) + + t.Run("filters by an exact id", func(t *testing.T) { + users, total, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: fmt.Sprintf("id eq %q", seedFixedID)}) + require.NoError(t, err) + assert.Equal(t, 1, total) + assert.Equal(t, []string{seedWorkEmailUserName}, userNamesOf(users)) + }) + + t.Run("filters id as text with co", func(t *testing.T) { + users, _, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `id co "aaaaaaaa"`}) + require.NoError(t, err) + assert.Equal(t, []string{seedWorkEmailUserName}, userNamesOf(users)) + }) + + t.Run("compares a typed datetime attribute", func(t *testing.T) { + users, total, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: `meta.lastModified gt "2020-01-01T00:00:00Z"`}) + require.NoError(t, err) + assert.Equal(t, count-1, total) + assert.NotContains(t, userNamesOf(users), seedAncientUserName) + }) + + t.Run("rejects invalid filters", func(t *testing.T) { + cases := []struct { + name string + filter string + }{ + {name: "invalid operator for boolean", filter: `active gt true`}, + {name: "unknown attribute", filter: `nickName eq "x"`}, + {name: "mistyped value", filter: `active eq "yes"`}, + {name: "malformed filter", filter: `userName zz "x"`}, + {name: "id eq a non-uuid value", filter: `id eq "not-a-uuid"`}, + {name: "co is not valid for a boolean value-path sub-attribute", filter: `emails[primary co "true"]`}, + {name: "co is not valid for a boolean top-level attribute", filter: `active co "true"`}, + {name: "sw is not valid for a datetime top-level attribute", filter: `meta.created sw "2026"`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := repository.List(ctx, &protocol.SearchRequest{StartIndex: 1, Count: count, Filter: tc.filter}) + require.Error(t, err) + }) + } + }) + }) + + 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) + }) + }) +} + +const ( + seedInactiveUserName = "inactive@example.com" + seedDisplayUserName = "strange@example.com" + seedWorkEmailUserName = "work@example.com" + seedLikeUserName = "a_b%c\\d@example.com" + seedLikeDecoyUserName = "axbzcd@example.com" + seedDisplayXUserName = "letterx@example.com" + seedAncientUserName = "ancient@example.com" + seedFixedID = "aaaaaaaa-0000-0000-0000-000000000001" +) + +func seedUsers() []*core.User { + inactive := false + base := time.Now().Add(-1 * time.Hour).UTC() + ancient := time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC) + + type seed struct { + id string + userName string + displayName string + active *bool + emails []core.Email + created time.Time + } + + seeds := []seed{ + {userName: "Zoe@example.com"}, + {userName: "alice@example.com"}, + {userName: "a-z@example.com"}, + {userName: "ab@example.com"}, + {userName: "BJensen@example.com"}, + {userName: "bob@example.com"}, + {userName: "carol1@example.com"}, + {userName: "carol-1@example.com"}, + {userName: "Dave@example.com"}, + {userName: "eve@example.com"}, + {userName: "Frank@example.com"}, + {userName: "user-00@example.com"}, + {userName: seedInactiveUserName, active: &inactive}, + {userName: seedDisplayUserName, displayName: "Dr. Strange"}, + {userName: seedWorkEmailUserName, id: seedFixedID, emails: []core.Email{{Type: "work", Value: "member@example.com"}}}, + {userName: seedLikeUserName}, + {userName: seedLikeDecoyUserName}, + {userName: seedDisplayXUserName, displayName: "x"}, + {userName: seedAncientUserName, created: ancient}, + } + + users := make([]*core.User, 0, len(seeds)) + for i, s := range seeds { + created := s.created + if created.IsZero() { + created = base + } + id := s.id + if id == "" { + id = fmt.Sprintf("00000000-0000-0000-0000-%012d", i+1) + } + users = append(users, &core.User{ + ID: id, + UserName: s.userName, + DisplayName: s.displayName, + Active: s.active, + Emails: s.emails, + Meta: core.Meta{ + Created: created, + LastModified: created, + }, + }) + } + 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/user_service.go b/internal/api/scim/user_service.go new file mode 100644 index 0000000000..d9392f3a0a --- /dev/null +++ b/internal/api/scim/user_service.go @@ -0,0 +1,37 @@ +package scim + +import ( + "context" + + "github.com/supabase-community/scim-go/pkg/core" + "github.com/supabase-community/scim-go/pkg/protocol" +) + +// Pass-through today; the seam for future SAML identity-linking logic, see Service[T]. +type userService struct { + repo Repository[*core.User] +} + +func NewUserService(repo Repository[*core.User]) Service[*core.User] { + return &userService{repo: repo} +} + +func (s *userService) Get(ctx context.Context, id string) (*core.User, error) { + return s.repo.Get(ctx, id) +} + +func (s *userService) List(ctx context.Context, query *protocol.SearchRequest) ([]*core.User, int, error) { + return s.repo.List(ctx, query) +} + +func (s *userService) Create(ctx context.Context, item *core.User) (*core.User, error) { + return s.repo.Create(ctx, item) +} + +func (s *userService) Replace(ctx context.Context, id string, item *core.User) (*core.User, error) { + return s.repo.Replace(ctx, id, item) +} + +func (s *userService) Delete(ctx context.Context, id string) error { + return s.repo.Delete(ctx, id) +} 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..bbbc49c282 --- /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..9dc34d4a37 --- /dev/null +++ b/internal/models/scim_token_test.go @@ -0,0 +1,56 @@ +package models + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewSCIMTokenPrefix(t *testing.T) { + token, plaintext := NewSCIMToken(&SSOProvider{}) + + t.Run("stores only a short leading identifier", func(t *testing.T) { + assert.Equal(t, plaintext[:8], token.Prefix) + }) + + t.Run("never persists the secret body in the prefix", func(t *testing.T) { + secret := plaintext[len(SCIMTokenPrefix):] + assert.NotContains(t, secret, token.Prefix) + }) +} + +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..98c2a0f0a7 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..335daa7574 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,91 @@ 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("rejects a bogus token even when a live token has a future expiry", func() { + provider := newProvider() + + token, _ := NewSCIMToken(provider) + expiresAt := time.Now().Add(1 * time.Hour) + token.ExpiresAt = &expiresAt + require.NoError(ts.T(), ts.db.Create(token)) + + got, err := FindSSOProviderBySCIMToken(ts.db, "scim_bogusbogusbogusbogusbogusbog") + 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) + }) +}