From c50dde57feb5967a10e66c664601dd633a074812 Mon Sep 17 00:00:00 2001 From: mo khan Date: Thu, 30 Jul 2026 16:29:49 -0600 Subject: [PATCH 1/2] feat(scim): implement ServiceProviderConfig Add a core package for SCIM and use it to answer `GET /scim/v2/ServiceProviderConfig`. --- internal/api/scim/core/core.go | 8 +++ internal/api/scim/core/endpoints.go | 6 ++ internal/api/scim/core/meta.go | 14 ++++ internal/api/scim/core/meta_test.go | 39 +++++++++++ internal/api/scim/core/schemas.go | 13 ++++ .../api/scim/core/service_provider_config.go | 70 +++++++++++++++++++ .../scim/core/service_provider_config_test.go | 66 +++++++++++++++++ internal/api/scim/server.go | 11 ++- internal/api/scim/server_test.go | 28 +++++++- .../api/scim/testdata/method_not_allowed.json | 7 ++ .../testdata/service_provider_config.json | 39 +++++++++++ internal/api/scim_test.go | 32 +++++++-- 12 files changed, 324 insertions(+), 9 deletions(-) create mode 100644 internal/api/scim/core/core.go create mode 100644 internal/api/scim/core/endpoints.go create mode 100644 internal/api/scim/core/meta.go create mode 100644 internal/api/scim/core/meta_test.go create mode 100644 internal/api/scim/core/schemas.go create mode 100644 internal/api/scim/core/service_provider_config.go create mode 100644 internal/api/scim/core/service_provider_config_test.go create mode 100644 internal/api/scim/testdata/method_not_allowed.json create mode 100644 internal/api/scim/testdata/service_provider_config.json diff --git a/internal/api/scim/core/core.go b/internal/api/scim/core/core.go new file mode 100644 index 000000000..d625dab4e --- /dev/null +++ b/internal/api/scim/core/core.go @@ -0,0 +1,8 @@ +// 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 new file mode 100644 index 000000000..b1f9003df --- /dev/null +++ b/internal/api/scim/core/endpoints.go @@ -0,0 +1,6 @@ +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 new file mode 100644 index 000000000..a47e4a4b3 --- /dev/null +++ b/internal/api/scim/core/meta.go @@ -0,0 +1,14 @@ +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 new file mode 100644 index 000000000..4b7383bd7 --- /dev/null +++ b/internal/api/scim/core/meta_test.go @@ -0,0 +1,39 @@ +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 new file mode 100644 index 000000000..128b2ea71 --- /dev/null +++ b/internal/api/scim/core/schemas.go @@ -0,0 +1,13 @@ +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 new file mode 100644 index 000000000..26c64da94 --- /dev/null +++ b/internal/api/scim/core/service_provider_config.go @@ -0,0 +1,70 @@ +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 new file mode 100644 index 000000000..03ff2dca9 --- /dev/null +++ b/internal/api/scim/core/service_provider_config_test.go @@ -0,0 +1,66 @@ +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/server.go b/internal/api/scim/server.go index b6313f049..8692342b8 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -2,7 +2,9 @@ package scim import ( "net/http" + "strings" + "github.com/supabase/auth/internal/api/scim/core" "github.com/supabase/auth/internal/api/scim/protocol" "github.com/supabase/auth/internal/conf" ) @@ -10,17 +12,20 @@ import ( const BasePath = "/scim/v2" type Server struct { - config *conf.GlobalConfiguration + serviceProviderConfig *core.ServiceProviderConfig } func NewServer(config *conf.GlobalConfiguration) *Server { return &Server{ - config: config, + serviceProviderConfig: core.NewServiceProviderConfig( + strings.TrimRight(config.API.ExternalURL, "/")+BasePath, + core.NewOAuthBearerToken().AsPrimary(), + ), } } func (srv *Server) ServiceProviderConfig(w http.ResponseWriter, r *http.Request) error { - return srv.notImplemented(w) + return protocol.Send(w, http.StatusOK, srv.serviceProviderConfig) } func (srv *Server) ResourceTypes(w http.ResponseWriter, r *http.Request) error { diff --git a/internal/api/scim/server_test.go b/internal/api/scim/server_test.go index 1a1e690ca..55f6541f3 100644 --- a/internal/api/scim/server_test.go +++ b/internal/api/scim/server_test.go @@ -7,6 +7,8 @@ import ( "testing" "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/api/scim/protocol" + "github.com/supabase/auth/internal/conf" ) //go:embed testdata/* @@ -18,15 +20,37 @@ 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) { - srv := NewServer(nil) + srv := newServerFor("http://localhost:9999") require.NotNil(t, srv) + t.Run("NewServer trims a trailing slash from the external URL", func(t *testing.T) { + location := newServerFor("https://auth.example.com/").serviceProviderConfig.Meta.Location + + require.Equal(t, "https://auth.example.com"+BasePath+"/ServiceProviderConfig", location) + }) + + t.Run("ServiceProviderConfig", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, BasePath+"/ServiceProviderConfig", nil) + w := httptest.NewRecorder() + + require.NoError(t, srv.ServiceProviderConfig(w, r)) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + 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 }{ - {"ServiceProviderConfig", srv.ServiceProviderConfig}, {"ResourceTypes", srv.ResourceTypes}, {"Schemas", srv.Schemas}, } { diff --git a/internal/api/scim/testdata/method_not_allowed.json b/internal/api/scim/testdata/method_not_allowed.json new file mode 100644 index 000000000..cebc0fc81 --- /dev/null +++ b/internal/api/scim/testdata/method_not_allowed.json @@ -0,0 +1,7 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + "status": "405", + "detail": "The request method is not supported by this endpoint" +} diff --git a/internal/api/scim/testdata/service_provider_config.json b/internal/api/scim/testdata/service_provider_config.json new file mode 100644 index 000000000..22b233771 --- /dev/null +++ b/internal/api/scim/testdata/service_provider_config.json @@ -0,0 +1,39 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" + ], + "patch": { + "supported": false + }, + "bulk": { + "supported": false, + "maxOperations": 0, + "maxPayloadSize": 0 + }, + "filter": { + "supported": false, + "maxResults": 0 + }, + "changePassword": { + "supported": false + }, + "sort": { + "supported": false + }, + "etag": { + "supported": false + }, + "authenticationSchemes": [ + { + "type": "oauthbearertoken", + "name": "OAuth Bearer Token", + "description": "Authentication scheme using the OAuth Bearer Token Standard", + "specUri": "http://www.rfc-editor.org/info/rfc6750", + "primary": true + } + ], + "meta": { + "resourceType": "ServiceProviderConfig", + "location": "http://localhost:9999/scim/v2/ServiceProviderConfig" + } +} diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 49b31db1d..13dde2ed5 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -6,15 +6,28 @@ import ( "testing" "github.com/stretchr/testify/require" + scimCore "github.com/supabase/auth/internal/api/scim/core" scimProtocol "github.com/supabase/auth/internal/api/scim/protocol" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/storage" ) +const ( + scimServiceProviderConfigPath = "/scim/v2/ServiceProviderConfig" + scimResourceTypesPath = "/scim/v2/ResourceTypes" + scimSchemasPath = "/scim/v2/Schemas" +) + var scimPaths = []string{ - "/scim/v2/ServiceProviderConfig", - "/scim/v2/ResourceTypes", - "/scim/v2/Schemas", + scimServiceProviderConfigPath, + scimResourceTypesPath, + scimSchemasPath, +} + +// scimNotImplementedPaths shrinks to empty as the endpoints land. +var scimNotImplementedPaths = []string{ + scimResourceTypesPath, + scimSchemasPath, } func TestSCIM(t *testing.T) { @@ -56,7 +69,18 @@ func TestSCIM(t *testing.T) { require.True(t, api.config.Experimental.ScimEnabled) - for _, path := range scimPaths { + t.Run(scimServiceProviderConfigPath, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, scimServiceProviderConfigPath, 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(), scimCore.SchemaServiceProviderConfig) + }) + + for _, path := range scimNotImplementedPaths { t.Run(path, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() From e0e676bf48e1acc53a9a4d9b6c343ed5fb82f58e Mon Sep 17 00:00:00 2001 From: mo khan Date: Thu, 6 Aug 2026 12:46:45 -0600 Subject: [PATCH 2/2] chore: remove unused fixture file --- internal/api/scim/testdata/method_not_allowed.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 internal/api/scim/testdata/method_not_allowed.json diff --git a/internal/api/scim/testdata/method_not_allowed.json b/internal/api/scim/testdata/method_not_allowed.json deleted file mode 100644 index cebc0fc81..000000000 --- a/internal/api/scim/testdata/method_not_allowed.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "schemas": [ - "urn:ietf:params:scim:api:messages:2.0:Error" - ], - "status": "405", - "detail": "The request method is not supported by this endpoint" -}