diff --git a/internal/api/api.go b/internal/api/api.go index d818f4a129..96a371e3d7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -138,6 +138,8 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne api.oauthServer = oauthserver.NewServer(globalConfig, db, api.tokenService) } + api.scim = scim.NewServer(globalConfig) + if api.config.Password.HIBP.Enabled { httpClient := &http.Client{ // all HIBP API requests should finish quickly to avoid @@ -213,14 +215,6 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.Post("/", api.ExternalProviderCallback) }) - api.scim = scim.NewServer(globalConfig) - r.Route("/scim/v2", func(r *router) { - r.Use(api.scim.Middleware) - r.Get("/ServiceProviderConfig", api.scim.ServiceProviderConfig) - r.Get("/ResourceTypes", api.scim.ResourceTypes) - r.Get("/Schemas", api.scim.Schemas) - }) - r.Route("/", func(r *router) { r.Use(api.isValidExternalHost) @@ -456,6 +450,15 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.With(api.requireAuthentication).Get("/authorizations/{authorization_id}", api.oauthServer.OAuthServerGetAuthorization) r.With(api.requireAuthentication).Post("/authorizations/{authorization_id}/consent", api.oauthServer.OAuthServerConsent) }) + + r.Route(scim.BasePath, func(r *router) { + r.Use(api.requireScimEnabled) + r.NotFound(api.scim.NotFound) + r.MethodNotAllowed(api.scim.MethodNotAllowed) + r.Get("/ResourceTypes", api.scim.ResourceTypes) + r.Get("/Schemas", api.scim.Schemas) + r.Get("/ServiceProviderConfig", api.scim.ServiceProviderConfig) + }) }) corsHandler := cors.New(cors.Options{ diff --git a/internal/api/errors.go b/internal/api/errors.go index 1d13ce2fd5..cfddecdc97 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -10,6 +10,7 @@ import ( "github.com/pkg/errors" "github.com/supabase/auth/internal/api/apierrors" + scimProtocol "github.com/supabase/auth/internal/api/scim/protocol" "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/utilities" ) @@ -38,6 +39,7 @@ var oauthErrorMap = map[int]string{ type ( HTTPError = apierrors.HTTPError OAuthError = apierrors.OAuthError + SCIMError = scimProtocol.Error ) // Recoverer is a middleware that recovers from panics, logs the panic (and a @@ -183,6 +185,13 @@ func HandleResponseError(err error, w http.ResponseWriter, r *http.Request) { log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter") } + case *SCIMError: + observability.LogEntrySetField(r, "error", e.Error()) + + if jsonErr := scimProtocol.Send(w, e.StatusCode(), e); jsonErr != nil && jsonErr != context.DeadlineExceeded { + log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter") + } + case ErrorCause: HandleResponseError(e.Cause(), w, r) diff --git a/internal/api/errors_test.go b/internal/api/errors_test.go index a2dcc6bca4..68a264551f 100644 --- a/internal/api/errors_test.go +++ b/internal/api/errors_test.go @@ -11,6 +11,8 @@ import ( "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/api/scim/fixtures" + "github.com/supabase/auth/internal/api/scim/protocol" "github.com/supabase/auth/internal/conf/confload" "github.com/supabase/auth/internal/observability" ) @@ -140,6 +142,21 @@ func TestHandleResponseErrorConsolidatesLogs(t *testing.T) { expectedError: "Password is too weak", expectedCode: string(apierrors.ErrorCodeWeakPassword), }, + { + name: "scim 404 error", + err: protocol.NewError(http.StatusNotFound, "", "Endpoint or resource does not exist"), + expectedError: "404: Endpoint or resource does not exist", + }, + { + name: "scim 403 error", + err: protocol.NewError(http.StatusForbidden, "", "Filtering is not supported on this endpoint"), + expectedError: "403: Filtering is not supported on this endpoint", + }, + { + name: "scim 400 error with a scimType", + err: protocol.NewError(http.StatusBadRequest, protocol.ErrorInvalidFilter, "The specified filter syntax was invalid"), + expectedError: "400: The specified filter syntax was invalid", + }, { name: "unhandled error", err: errors.New("unexpected failure"), @@ -178,3 +195,39 @@ func TestHandleResponseErrorConsolidatesLogs(t *testing.T) { }) } } + +func TestHandleResponseErrorWithSCIMError(t *testing.T) { + for _, example := range []struct { + scimErr *protocol.Error + expectedBody string + }{ + { + scimErr: protocol.NewError(http.StatusForbidden, "", "Filtering is not supported on this endpoint"), + expectedBody: fixtures.FilterForbidden, + }, + { + scimErr: protocol.NewError(http.StatusBadRequest, protocol.ErrorInvalidFilter, "The specified filter syntax was invalid"), + expectedBody: fixtures.InvalidFilter, + }, + { + scimErr: protocol.NewError(http.StatusNotFound, "", "Endpoint or resource does not exist"), + expectedBody: fixtures.NotFound, + }, + { + scimErr: protocol.NewError(http.StatusMethodNotAllowed, "", "The request method is not supported by this endpoint"), + expectedBody: fixtures.MethodNotAllowed, + }, + } { + t.Run(example.scimErr.Status, func(t *testing.T) { + rec := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "http://example.com/scim/v2/Schemas", nil) + require.NoError(t, err) + + HandleResponseError(example.scimErr, rec, req) + + require.Equal(t, example.scimErr.StatusCode(), rec.Code) + require.Equal(t, protocol.MediaType, rec.Header().Get("Content-Type")) + require.JSONEq(t, example.expectedBody, rec.Body.String()) + }) + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 37299849f8..756e67e773 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -412,6 +412,14 @@ func (a *API) requireCustomOAuthEnabled(w http.ResponseWriter, req *http.Request return ctx, nil } +func (a *API) requireScimEnabled(w http.ResponseWriter, req *http.Request) (context.Context, error) { + ctx := req.Context() + if !a.config.Experimental.ScimEnabled { + return nil, apierrors.NewNotFoundError(apierrors.ErrorCodeFeatureDisabled, "SCIM server is disabled") + } + return ctx, nil +} + func (a *API) requirePasskeyEnabled(w http.ResponseWriter, req *http.Request) (context.Context, error) { ctx := req.Context() if !a.config.Passkey.Enabled { diff --git a/internal/api/router.go b/internal/api/router.go index 0a01f55fb4..bdd1ca0a7d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -36,7 +36,6 @@ func (r *router) Patch(pattern string, fn apiHandler) { func (r *router) Delete(pattern string, fn apiHandler) { r.chi.Delete(pattern, handler(fn)) } - func (r *router) With(fn middlewareHandler) *router { c := r.chi.With(middleware(fn)) return &router{c} @@ -50,10 +49,19 @@ func (r *router) WithBypass(fn func(next http.Handler) http.Handler) *router { func (r *router) Use(fn middlewareHandler) { r.chi.Use(middleware(fn)) } + func (r *router) UseBypass(fn func(next http.Handler) http.Handler) { r.chi.Use(fn) } +func (r *router) NotFound(fn apiHandler) { + r.chi.NotFound(handler(fn)) +} + +func (r *router) MethodNotAllowed(fn apiHandler) { + r.chi.MethodNotAllowed(handler(fn)) +} + func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.chi.ServeHTTP(w, req) } diff --git a/internal/api/scim/core/meta.go b/internal/api/scim/core/meta.go new file mode 100644 index 0000000000..70e4307a83 --- /dev/null +++ b/internal/api/scim/core/meta.go @@ -0,0 +1,7 @@ +package core + +// Meta is the resource metadata common attribute defined in RFC 7643, Section 3.1. +type Meta struct { + ResourceType string `json:"resourceType"` + Location string `json:"location,omitempty"` +} diff --git a/internal/api/scim/core/meta_test.go b/internal/api/scim/core/meta_test.go new file mode 100644 index 0000000000..afc4bcd411 --- /dev/null +++ b/internal/api/scim/core/meta_test.go @@ -0,0 +1,21 @@ +package core + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/api/scim/fixtures" +) + +func TestMeta(t *testing.T) { + t.Run("serializes to JSON correctly", func(t *testing.T) { + body, err := json.Marshal(Meta{ + ResourceType: "ServiceProviderConfig", + Location: "http://localhost:9999/scim/v2/ServiceProviderConfig", + }) + + require.NoError(t, err) + require.JSONEq(t, fixtures.MetaServiceProviderConfig, string(body)) + }) +} 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 0000000000..e8d073cdf3 --- /dev/null +++ b/internal/api/scim/core/service_provider_config.go @@ -0,0 +1,63 @@ +// Package core implements the SCIM 2.0 core schema defined in RFC 7643. +package core + +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 SupportedFeature struct { + Supported bool `json:"supported"` +} + +type AuthenticationScheme struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + SpecURI string `json:"specUri,omitempty"` + Primary bool `json:"primary"` +} + +// OAuthBearerToken is the scheme described in RFC 7643, Section 8.5. +func OAuthBearerToken() AuthenticationScheme { + return AuthenticationScheme{ + Type: "oauthbearertoken", + 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 +} + +type ServiceProviderConfig struct { + Schemas []string `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 { + return &ServiceProviderConfig{ + Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"}, + AuthenticationSchemes: append(make([]AuthenticationScheme, 0, len(schemes)), schemes...), + Meta: Meta{ + ResourceType: "ServiceProviderConfig", + Location: baseURL + "/ServiceProviderConfig", + }, + } +} 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 0000000000..fcd664e76b --- /dev/null +++ b/internal/api/scim/core/service_provider_config_test.go @@ -0,0 +1,62 @@ +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) { + schemes := []AuthenticationScheme{OAuthBearerToken().AsPrimary()} + + config := NewServiceProviderConfig("", schemes) + + require.Equal(t, []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"}, config.Schemas) + require.Equal(t, schemes, config.AuthenticationSchemes) + }) + + t.Run("identifies itself with resource metadata", func(t *testing.T) { + baseURL := "http://localhost:9999/scim/v2" + config := NewServiceProviderConfig(baseURL, nil) + + require.Equal(t, "ServiceProviderConfig", config.Meta.ResourceType) + require.Equal(t, baseURL+"/ServiceProviderConfig", config.Meta.Location) + }) + + t.Run("supports none of the optional protocol features", func(t *testing.T) { + config := NewServiceProviderConfig("", nil) + + require.False(t, config.Patch.Supported) + require.False(t, config.Bulk.Supported) + require.False(t, config.Filter.Supported) + require.False(t, config.ChangePassword.Supported) + require.False(t, config.Sort.Supported) + require.False(t, config.ETag.Supported) + }) + + t.Run("serializes authenticationSchemes as an array", func(t *testing.T) { + body, err := json.Marshal(NewServiceProviderConfig("", nil)) + + require.NoError(t, err) + require.Contains(t, string(body), `"authenticationSchemes":[]`) + }) +} + +func TestAuthenticationScheme(t *testing.T) { + t.Run("OAuthBearerToken", func(t *testing.T) { + scheme := OAuthBearerToken() + + require.Equal(t, "oauthbearertoken", scheme.Type) + require.Equal(t, "OAuth Bearer Token", scheme.Name) + require.Equal(t, "Authentication scheme using the OAuth Bearer Token Standard", scheme.Description) + require.Equal(t, "http://www.rfc-editor.org/info/rfc6750", scheme.SpecURI) + }) + + t.Run("AsPrimary", func(t *testing.T) { + assert.False(t, OAuthBearerToken().Primary) + assert.True(t, OAuthBearerToken().AsPrimary().Primary) + }) +} diff --git a/internal/api/scim/fixtures/empty_list_response.json b/internal/api/scim/fixtures/empty_list_response.json new file mode 100644 index 0000000000..d13e376c64 --- /dev/null +++ b/internal/api/scim/fixtures/empty_list_response.json @@ -0,0 +1,9 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ], + "totalResults": 0, + "startIndex": 1, + "itemsPerPage": 0, + "Resources": [] +} diff --git a/internal/api/scim/fixtures/filter_forbidden.json b/internal/api/scim/fixtures/filter_forbidden.json new file mode 100644 index 0000000000..5f060363c6 --- /dev/null +++ b/internal/api/scim/fixtures/filter_forbidden.json @@ -0,0 +1,7 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + "detail": "Filtering is not supported on this endpoint", + "status": "403" +} diff --git a/internal/api/scim/fixtures/fixtures.go b/internal/api/scim/fixtures/fixtures.go new file mode 100644 index 0000000000..a6110b96b8 --- /dev/null +++ b/internal/api/scim/fixtures/fixtures.go @@ -0,0 +1,28 @@ +// Package fixtures holds the expected SCIM wire payloads shared by tests. +package fixtures + +import _ "embed" + +//go:embed empty_list_response.json +var EmptyListResponse string + +//go:embed filter_forbidden.json +var FilterForbidden string + +//go:embed invalid_filter.json +var InvalidFilter string + +//go:embed method_not_allowed.json +var MethodNotAllowed string + +//go:embed method_not_allowed_without_detail.json +var MethodNotAllowedWithoutDetail string + +//go:embed meta_service_provider_config.json +var MetaServiceProviderConfig string + +//go:embed not_found.json +var NotFound string + +//go:embed service_provider_config.json +var ServiceProviderConfig string diff --git a/internal/api/scim/fixtures/invalid_filter.json b/internal/api/scim/fixtures/invalid_filter.json new file mode 100644 index 0000000000..12256c324d --- /dev/null +++ b/internal/api/scim/fixtures/invalid_filter.json @@ -0,0 +1,8 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + "scimType": "invalidFilter", + "detail": "The specified filter syntax was invalid", + "status": "400" +} diff --git a/internal/api/scim/fixtures/meta_service_provider_config.json b/internal/api/scim/fixtures/meta_service_provider_config.json new file mode 100644 index 0000000000..7730e01e4c --- /dev/null +++ b/internal/api/scim/fixtures/meta_service_provider_config.json @@ -0,0 +1,4 @@ +{ + "resourceType": "ServiceProviderConfig", + "location": "http://localhost:9999/scim/v2/ServiceProviderConfig" +} diff --git a/internal/api/scim/fixtures/method_not_allowed.json b/internal/api/scim/fixtures/method_not_allowed.json new file mode 100644 index 0000000000..cebc0fc81e --- /dev/null +++ b/internal/api/scim/fixtures/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/fixtures/method_not_allowed_without_detail.json b/internal/api/scim/fixtures/method_not_allowed_without_detail.json new file mode 100644 index 0000000000..bbc2d945fb --- /dev/null +++ b/internal/api/scim/fixtures/method_not_allowed_without_detail.json @@ -0,0 +1,6 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + "status": "405" +} diff --git a/internal/api/scim/fixtures/not_found.json b/internal/api/scim/fixtures/not_found.json new file mode 100644 index 0000000000..4d241ba672 --- /dev/null +++ b/internal/api/scim/fixtures/not_found.json @@ -0,0 +1,7 @@ +{ + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + "status": "404", + "detail": "Endpoint or resource does not exist" +} diff --git a/internal/api/scim/fixtures/service_provider_config.json b/internal/api/scim/fixtures/service_provider_config.json new file mode 100644 index 0000000000..22b2337714 --- /dev/null +++ b/internal/api/scim/fixtures/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/protocol/error.go b/internal/api/scim/protocol/error.go new file mode 100644 index 0000000000..be02c5067d --- /dev/null +++ b/internal/api/scim/protocol/error.go @@ -0,0 +1,55 @@ +package protocol + +import ( + "net/http" + "strconv" +) + +const SchemaError = "urn:ietf:params:scim:api:messages:2.0:Error" + +// Error keywords defined in RFC 7644, Section 3.12 +const ( + ErrorInvalidFilter = "invalidFilter" + ErrorTooMany = "tooMany" + ErrorUniqueness = "uniqueness" + ErrorMutability = "mutability" + ErrorInvalidSyntax = "invalidSyntax" + ErrorInvalidPath = "invalidPath" + ErrorNoTarget = "noTarget" + ErrorInvalidValue = "invalidValue" + ErrorInvalidVers = "invalidVers" + ErrorSensitive = "sensitive" +) + +// 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}, + Status: strconv.Itoa(status), + ScimType: scimType, + Detail: detail, + } +} + +func (e *Error) StatusCode() int { + status, err := strconv.Atoi(e.Status) + if err != nil { + return http.StatusInternalServerError + } + return status +} + +func (e *Error) Error() string { + detail := e.Detail + if detail == "" { + detail = http.StatusText(e.StatusCode()) + } + return e.Status + ": " + detail +} diff --git a/internal/api/scim/protocol/error_test.go b/internal/api/scim/protocol/error_test.go new file mode 100644 index 0000000000..eefebf6376 --- /dev/null +++ b/internal/api/scim/protocol/error_test.go @@ -0,0 +1,61 @@ +package protocol + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/api/scim/fixtures" +) + +func TestNewError(t *testing.T) { + t.Run("serializes to JSON correctly", func(t *testing.T) { + body, err := json.Marshal(NewError(http.StatusBadRequest, ErrorInvalidFilter, "The specified filter syntax was invalid")) + + require.NoError(t, err) + assert.JSONEq(t, fixtures.InvalidFilter, string(body)) + }) + + t.Run("omits the optional attributes when they are empty", func(t *testing.T) { + body, err := json.Marshal(NewError(http.StatusMethodNotAllowed, "", "")) + + require.NoError(t, err) + assert.JSONEq(t, fixtures.MethodNotAllowedWithoutDetail, string(body)) + }) +} + +func TestErrorStatusCode(t *testing.T) { + t.Run("reads the status back from the wire form", func(t *testing.T) { + require.Equal(t, http.StatusForbidden, NewError(http.StatusForbidden, "", "").StatusCode()) + }) + + t.Run("survives a round trip through JSON", func(t *testing.T) { + var scimErr Error + require.NoError(t, json.Unmarshal([]byte(fixtures.NotFound), &scimErr)) + + assert.Equal(t, http.StatusNotFound, scimErr.StatusCode()) + assert.Equal(t, "404", scimErr.Status) + assert.Equal(t, "Endpoint or resource does not exist", scimErr.Detail) + assert.Equal(t, []string{"urn:ietf:params:scim:api:messages:2.0:Error"}, scimErr.Schemas) + }) + + t.Run("falls back to a server error when the status is not a number", func(t *testing.T) { + require.Equal(t, http.StatusInternalServerError, (&Error{Status: "nonsense"}).StatusCode()) + }) +} + +func TestErrorError(t *testing.T) { + t.Run("reads as an error", func(t *testing.T) { + var err error = NewError(http.StatusNotFound, "", "Endpoint or resource does not exist") + + assert.EqualError(t, err, "404: Endpoint or resource does not exist") + }) + + t.Run("falls back to the status text when there is no detail", func(t *testing.T) { + var err error = NewError(http.StatusMethodNotAllowed, "", "") + + require.EqualError(t, err, "405: Method Not Allowed") + }) +} diff --git a/internal/api/scim/protocol/list_response.go b/internal/api/scim/protocol/list_response.go new file mode 100644 index 0000000000..16afa97044 --- /dev/null +++ b/internal/api/scim/protocol/list_response.go @@ -0,0 +1,22 @@ +package protocol + +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{} + } + return &ListResponse[T]{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:ListResponse"}, + TotalResults: len(resources), + StartIndex: 1, + ItemsPerPage: len(resources), + Resources: resources, + } +} diff --git a/internal/api/scim/protocol/list_response_test.go b/internal/api/scim/protocol/list_response_test.go new file mode 100644 index 0000000000..c83974050a --- /dev/null +++ b/internal/api/scim/protocol/list_response_test.go @@ -0,0 +1,46 @@ +package protocol + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/supabase/auth/internal/api/scim/fixtures" +) + +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: fixtures.EmptyListResponse, + }, + { + name: "empty resources marshal to an empty array", + resources: []string{}, + expected: fixtures.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) { + b, err := json.Marshal(NewListResponse(tc.resources)) + require.NoError(t, err) + require.JSONEq(t, tc.expected, string(b)) + }) + } +} diff --git a/internal/api/scim/protocol/protocol.go b/internal/api/scim/protocol/protocol.go new file mode 100644 index 0000000000..0bd879b0a7 --- /dev/null +++ b/internal/api/scim/protocol/protocol.go @@ -0,0 +1,15 @@ +package protocol + +import ( + "net/http" + + "github.com/supabase/auth/internal/api/shared" +) + +// MediaType is the SCIM media type defined in RFC 7644, Section 3.1. +const MediaType = "application/scim+json" + +// Send writes obj as a SCIM response body. +func Send(w http.ResponseWriter, status int, obj any) error { + return shared.SendJSONAs(w, status, MediaType, obj) +} diff --git a/internal/api/scim/protocol/protocol_test.go b/internal/api/scim/protocol/protocol_test.go new file mode 100644 index 0000000000..0fde3c40c5 --- /dev/null +++ b/internal/api/scim/protocol/protocol_test.go @@ -0,0 +1,22 @@ +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 an error with the SCIM media type", func(t *testing.T) { + w := httptest.NewRecorder() + + require.NoError(t, Send(w, http.StatusNotFound, map[string]string{"key": "value"})) + + assert.Equal(t, http.StatusNotFound, 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/server.go b/internal/api/scim/server.go index 4faba28c4d..740e313626 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -1,46 +1,60 @@ package scim import ( - "context" "net/http" + "strings" - "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/api/scim/core" + "github.com/supabase/auth/internal/api/scim/protocol" "github.com/supabase/auth/internal/conf" ) -const mediaType = "application/scim+json" +const BasePath = "/scim/v2" type Server struct { - config *conf.GlobalConfiguration + config *core.ServiceProviderConfig } func NewServer(config *conf.GlobalConfiguration) *Server { return &Server{ - config: config, + config: core.NewServiceProviderConfig( + strings.TrimRight(config.API.ExternalURL, "/")+BasePath, + []core.AuthenticationScheme{core.OAuthBearerToken().AsPrimary()}, + ), } } -func (srv *Server) Middleware(w http.ResponseWriter, r *http.Request) (context.Context, error) { - if !srv.config.Experimental.ScimEnabled { - return nil, apierrors.NewNotFoundError(apierrors.ErrorCodeFeatureDisabled, "SCIM server is disabled") - } - return r.Context(), nil -} - func (srv *Server) ServiceProviderConfig(w http.ResponseWriter, r *http.Request) error { - return srv.notImplemented(w, r) + return protocol.Send(w, http.StatusOK, srv.config) } func (srv *Server) ResourceTypes(w http.ResponseWriter, r *http.Request) error { - return srv.notImplemented(w, r) + if hasFilter(r) { + return filterForbidden() + } + return protocol.Send(w, http.StatusOK, protocol.NewListResponse([]any{})) } func (srv *Server) Schemas(w http.ResponseWriter, r *http.Request) error { - return srv.notImplemented(w, r) + if hasFilter(r) { + return filterForbidden() + } + return protocol.Send(w, http.StatusOK, protocol.NewListResponse([]any{})) +} + +func (srv *Server) NotFound(w http.ResponseWriter, r *http.Request) error { + return protocol.NewError(http.StatusNotFound, "", "Endpoint or resource does not exist") +} + +func (srv *Server) MethodNotAllowed(w http.ResponseWriter, r *http.Request) error { + w.Header().Set("Allow", http.MethodGet) + return protocol.NewError(http.StatusMethodNotAllowed, "", "The request method is not supported by this endpoint") +} + +func hasFilter(r *http.Request) bool { + return r.URL.Query().Get("filter") != "" } -func (srv *Server) notImplemented(w http.ResponseWriter, r *http.Request) error { - w.Header().Set("Content-Type", mediaType) - w.WriteHeader(http.StatusNotImplemented) - return nil +func filterForbidden() error { + return protocol.NewError(http.StatusForbidden, "", "Filtering is not supported on this endpoint") } diff --git a/internal/api/scim/server_test.go b/internal/api/scim/server_test.go index fd99b1cde4..6d3a3e9054 100644 --- a/internal/api/scim/server_test.go +++ b/internal/api/scim/server_test.go @@ -6,27 +6,33 @@ import ( "testing" "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/api/scim/fixtures" + "github.com/supabase/auth/internal/api/scim/protocol" + "github.com/supabase/auth/internal/conf/confload" ) func TestServer(t *testing.T) { - srv := NewServer(nil) - require.NotNil(t, srv) + globalConfig, err := confload.LoadGlobal("../../../hack/test.env") + require.NoError(t, err) + srv := NewServer(globalConfig) - for _, tc := range []struct { - path string + for _, endpoint := range []struct { + name string handler func(http.ResponseWriter, *http.Request) error + body string }{ - {"ServiceProviderConfig", srv.ServiceProviderConfig}, - {"ResourceTypes", srv.ResourceTypes}, - {"Schemas", srv.Schemas}, + {"ServiceProviderConfig", srv.ServiceProviderConfig, fixtures.ServiceProviderConfig}, + {"ResourceTypes", srv.ResourceTypes, fixtures.EmptyListResponse}, + {"Schemas", srv.Schemas, fixtures.EmptyListResponse}, } { - t.Run(tc.path, func(t *testing.T) { - r := httptest.NewRequest(http.MethodGet, "/scim/v2/"+tc.path, nil) + t.Run("GET "+BasePath+"/"+endpoint.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, BasePath+"/"+endpoint.name, nil) w := httptest.NewRecorder() - require.NoError(t, tc.handler(w, r)) - require.Equal(t, w.Code, http.StatusNotImplemented) - require.Equal(t, "application/scim+json", w.Header().Get("Content-Type")) + require.NoError(t, endpoint.handler(w, r)) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, endpoint.body, w.Body.String()) }) } } diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index c4bd9f9933..d09f412558 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -5,59 +5,102 @@ import ( "net/http/httptest" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/supabase/auth/internal/conf" - "github.com/supabase/auth/internal/storage" + "github.com/supabase/auth/internal/api/scim/fixtures" + "github.com/supabase/auth/internal/api/scim/protocol" ) -var scimPaths = []string{ - "/scim/v2/ServiceProviderConfig", - "/scim/v2/ResourceTypes", - "/scim/v2/Schemas", -} - func TestSCIM(t *testing.T) { + scimRoutes := []struct { + path string + body string + }{ + {"/scim/v2/ResourceTypes", fixtures.EmptyListResponse}, + {"/scim/v2/Schemas", fixtures.EmptyListResponse}, + {"/scim/v2/ServiceProviderConfig", fixtures.ServiceProviderConfig}, + } + t.Run("Disabled by default", func(t *testing.T) { api, _, err := setupAPIForTest() require.NoError(t, err) - require.False(t, api.config.Experimental.ScimEnabled) - for _, path := range scimPaths { - r := httptest.NewRequest(http.MethodGet, path, nil) - w := httptest.NewRecorder() - api.handler.ServeHTTP(w, r) + for _, route := range scimRoutes { + t.Run(route.path, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, route.path, nil) + w := httptest.NewRecorder() - require.Equal(t, http.StatusNotFound, w.Code) + api.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusNotFound, w.Code) + require.NotContains(t, w.Body.String(), protocol.SchemaError) + }) } }) - t.Run("Can be enabled", func(t *testing.T) { - api, _, err := setupAPIForTestWithCallback(func(config *conf.GlobalConfiguration, conn *storage.Connection) { - if config != nil { - config.Experimental.ScimEnabled = true - } - }) + t.Run("Mounted when enabled", func(t *testing.T) { + api, config, err := setupAPIForTest() require.NoError(t, err) + require.NotNil(t, api) + require.NotNil(t, config) + config.Experimental.ScimEnabled = true - require.True(t, api.config.Experimental.ScimEnabled) - require.NotNil(t, api.scim) - }) + for _, route := range scimRoutes { + t.Run(route.path, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, route.path, nil) + w := httptest.NewRecorder() - for _, path := range scimPaths { - t.Run(path, func(t *testing.T) { - api, _, err := setupAPIForTestWithCallback(func(config *conf.GlobalConfiguration, conn *storage.Connection) { - if config != nil { - config.Experimental.ScimEnabled = true - } + api.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, route.body, w.Body.String()) }) - require.NoError(t, err) + } - r := httptest.NewRequest(http.MethodGet, path, nil) + t.Run("Returns a SCIM 403 when filter query param is used", func(t *testing.T) { + for _, path := range []string{"/scim/v2/ResourceTypes", "/scim/v2/Schemas"} { + t.Run(path, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, path+`?filter=name%20eq%20%22User%22`, nil) + w := httptest.NewRecorder() + + api.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusForbidden, w.Code) + assert.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + assert.JSONEq(t, fixtures.FilterForbidden, w.Body.String()) + }) + } + }) + + t.Run("Returns a SCIM 404 error", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/scim/v2/Unknown", nil) w := httptest.NewRecorder() + api.handler.ServeHTTP(w, r) - require.Equal(t, w.Code, http.StatusNotImplemented) + require.Equal(t, http.StatusNotFound, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, fixtures.NotFound, w.Body.String()) }) - } + + t.Run("Returns a SCIM 405 error", func(t *testing.T) { + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + for _, path := range []string{"/scim/v2/ServiceProviderConfig", "/scim/v2/ResourceTypes", "/scim/v2/Schemas"} { + t.Run(method+" "+path, func(t *testing.T) { + r := httptest.NewRequest(method, path, nil) + w := httptest.NewRecorder() + + api.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusMethodNotAllowed, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.Equal(t, "GET", w.Header().Get("Allow")) + require.JSONEq(t, fixtures.MethodNotAllowed, w.Body.String()) + }) + } + } + }) + }) } diff --git a/internal/api/shared/http.go b/internal/api/shared/http.go index 4eec7cc828..084969da7a 100644 --- a/internal/api/shared/http.go +++ b/internal/api/shared/http.go @@ -9,12 +9,17 @@ import ( ) // SendJSON sends a JSON response with proper error handling -func SendJSON(w http.ResponseWriter, status int, obj interface{}) error { - w.Header().Set("Content-Type", "application/json") +func SendJSON(w http.ResponseWriter, status int, obj any) error { + return SendJSONAs(w, status, "application/json", obj) +} + +// SendJSONAs sends a JSON response using the given Content-Type +func SendJSONAs(w http.ResponseWriter, status int, contentType string, obj any) error { b, err := json.Marshal(obj) if err != nil { return errors.Wrap(err, fmt.Sprintf("Error encoding json response: %v", obj)) } + w.Header().Set("Content-Type", contentType) w.WriteHeader(status) _, err = w.Write(b) return err