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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
9 changes: 9 additions & 0 deletions internal/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
53 changes: 53 additions & 0 deletions internal/api/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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())
})
}
}
8 changes: 8 additions & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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)
}
Expand Down
7 changes: 7 additions & 0 deletions internal/api/scim/core/meta.go
Original file line number Diff line number Diff line change
@@ -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"`
}
21 changes: 21 additions & 0 deletions internal/api/scim/core/meta_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
63 changes: 63 additions & 0 deletions internal/api/scim/core/service_provider_config.go
Original file line number Diff line number Diff line change
@@ -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",
},
}
}
62 changes: 62 additions & 0 deletions internal/api/scim/core/service_provider_config_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
9 changes: 9 additions & 0 deletions internal/api/scim/fixtures/empty_list_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"schemas": [
"urn:ietf:params:scim:api:messages:2.0:ListResponse"
],
"totalResults": 0,
"startIndex": 1,
"itemsPerPage": 0,
"Resources": []
}
7 changes: 7 additions & 0 deletions internal/api/scim/fixtures/filter_forbidden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"schemas": [
"urn:ietf:params:scim:api:messages:2.0:Error"
],
"detail": "Filtering is not supported on this endpoint",
"status": "403"
}
28 changes: 28 additions & 0 deletions internal/api/scim/fixtures/fixtures.go
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions internal/api/scim/fixtures/invalid_filter.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading