diff --git a/internal/api/api.go b/internal/api/api.go index beebb26c0..75cc4cc04 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(globalConfig, db, api.extractBearerToken) if api.config.Password.HIBP.Enabled { httpClient := &http.Client{ @@ -458,6 +458,8 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.Get("/ServiceProviderConfig", api.scim.ServiceProviderConfig) r.Get("/ResourceTypes", api.scim.ResourceTypes) r.Get("/Schemas", api.scim.Schemas) + + r.WithBypass(api.scim.Authenticate).Get("/Users/{id}", api.scim.UserByID) }) }) diff --git a/internal/api/context.go b/internal/api/context.go index f8367a4ab..834621724 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -31,7 +31,6 @@ const ( adminUserKey = contextKey("admin_user") oauthTokenKey = contextKey("oauth_token") // for OAuth1.0, also known as request token oauthVerifierKey = contextKey("oauth_verifier") - ssoProviderKey = contextKey("sso_provider") externalHostKey = contextKey("external_host") oauthClientStateKey = contextKey("oauth_client_state_id") flowStateContextKey = contextKey("flow_state") @@ -239,15 +238,11 @@ func getOAuthVerifier(ctx context.Context) string { } func withSSOProvider(ctx context.Context, provider *models.SSOProvider) context.Context { - return context.WithValue(ctx, ssoProviderKey, provider) + return shared.WithSSOProvider(ctx, provider) } func getSSOProvider(ctx context.Context) *models.SSOProvider { - obj := ctx.Value(ssoProviderKey) - if obj == nil { - return nil - } - return obj.(*models.SSOProvider) + return shared.GetSSOProvider(ctx) } func withExternalHost(ctx context.Context, u *url.URL) context.Context { diff --git a/internal/api/samlacs.go b/internal/api/samlacs.go index e0a35df85..5c44f8eeb 100644 --- a/internal/api/samlacs.go +++ b/internal/api/samlacs.go @@ -311,7 +311,7 @@ func (a *API) handleSamlAcs(w http.ResponseWriter, r *http.Request) error { } } - providerType := "sso:" + ssoProvider.ID.String() + providerType := ssoProvider.ProviderType() if err := a.triggerBeforeUserCreatedExternal( r, db, &userProvidedData, providerType); err != nil { return err diff --git a/internal/api/scim/authenticate.go b/internal/api/scim/authenticate.go new file mode 100644 index 000000000..f2cf87a98 --- /dev/null +++ b/internal/api/scim/authenticate.go @@ -0,0 +1,55 @@ +package scim + +import ( + "context" + "net/http" + + "github.com/supabase/auth/internal/api/scim/protocol" + "github.com/supabase/auth/internal/api/shared" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/observability" +) + +func (srv *Server) Authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, ok := srv.authenticate(w, r) + if !ok { + return + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (srv *Server) authenticate(w http.ResponseWriter, r *http.Request) (context.Context, bool) { + ctx := r.Context() + + token, err := srv.extract(r) + if err != nil { + unauthorized(w) + return nil, false + } + + provider, err := models.FindSSOProviderBySCIMToken(srv.db.WithContext(ctx), token) + if err != nil { + if models.IsNotFoundError(err) { + unauthorized(w) + return nil, false + } + srv.internalError(w, r, err) + return nil, false + } + + if !provider.IsEnabled() { + protocol.SendError(w, http.StatusForbidden, "", "SCIM is not available for this provider") + return nil, false + } + + observability.LogEntrySetField(r, "sso_provider_id", provider.ID.String()) + + return shared.WithSSOProvider(ctx, provider), true +} + +func unauthorized(w http.ResponseWriter) error { + w.Header().Set("WWW-Authenticate", "Bearer") + return protocol.SendError(w, http.StatusUnauthorized, "", "A valid SCIM bearer token is required") +} diff --git a/internal/api/scim/core/authentication_scheme.go b/internal/api/scim/core/authentication_scheme.go new file mode 100644 index 000000000..d1af828eb --- /dev/null +++ b/internal/api/scim/core/authentication_scheme.go @@ -0,0 +1,29 @@ +package core + +type AuthenticationSchemeType string + +const ( + AuthenticationSchemeOAuthBearerToken AuthenticationSchemeType = "oauthbearertoken" +) + +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 +} diff --git a/internal/api/scim/core/core.go b/internal/api/scim/core/core.go index d625dab4e..3b8f92437 100644 --- a/internal/api/scim/core/core.go +++ b/internal/api/scim/core/core.go @@ -3,6 +3,3 @@ 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 b1f9003df..000000000 --- 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/feature.go b/internal/api/scim/core/feature.go new file mode 100644 index 000000000..42c14bf96 --- /dev/null +++ b/internal/api/scim/core/feature.go @@ -0,0 +1,16 @@ +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"` +} diff --git a/internal/api/scim/core/meta.go b/internal/api/scim/core/meta.go index a47e4a4b3..c03522189 100644 --- a/internal/api/scim/core/meta.go +++ b/internal/api/scim/core/meta.go @@ -1,14 +1,23 @@ package core -// Meta is the resource metadata common attribute defined in RFC 7643, Section 3.1. +import "time" + type Meta struct { ResourceType ResourceTypeName `json:"resourceType"` + Created time.Time `json:"created,omitzero"` + LastModified time.Time `json:"lastModified,omitzero"` Location string `json:"location,omitempty"` } -func NewMeta(baseURL string, resourceType ResourceTypeName, endpoint string) Meta { - return Meta{ - ResourceType: resourceType, - Location: baseURL + endpoint, - } +func NewMeta(baseURL string, resourceType ResourceType) Meta { + return resourceType.Meta(baseURL) +} + +func (m Meta) For(r Resource) Meta { + created, updated := r.Timestamps() + + m.Location += "/" + r.ResourceID() + m.Created, m.LastModified = created.UTC(), updated.UTC() + + return m } diff --git a/internal/api/scim/core/meta_test.go b/internal/api/scim/core/meta_test.go index 4b7383bd7..ad227564f 100644 --- a/internal/api/scim/core/meta_test.go +++ b/internal/api/scim/core/meta_test.go @@ -3,23 +3,81 @@ package core import ( "encoding/json" "testing" + "time" + "github.com/gofrs/uuid" "github.com/stretchr/testify/require" ) +type resource struct { + id string + created, updated time.Time +} + +func (s resource) ResourceID() string { return s.id } +func (s resource) ResourceType() ResourceType { + return ResourceType{Name: "Resource", Endpoint: "/Resources"} +} +func (s resource) Timestamps() (created, updated time.Time) { return s.created, s.updated } + 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) + baseURL := "http://localhost:9999/scim/v2" + id := uuid.Must(uuid.NewV4()).String() + created := time.Date(2026, 7, 21, 19, 41, 41, 0, time.UTC) + updated := time.Date(2026, 7, 22, 8, 12, 3, 0, time.UTC) + + t.Run("locates a resource that is its own endpoint", func(t *testing.T) { + meta := NewMeta(baseURL, ResourceTypeServiceProviderConfig) + + require.Equal(t, ResourceTypeServiceProviderConfig.Name, meta.ResourceType) + require.Equal(t, baseURL+"/ServiceProviderConfig", meta.Location) + require.Zero(t, meta.Created) + require.Zero(t, meta.LastModified) + }) + + t.Run("locates one resource of a collection", func(t *testing.T) { + meta := NewMeta(baseURL, ResourceTypeUser).For(resource{ + id: id, + created: created, + updated: updated, + }) + + require.Equal(t, ResourceTypeUser.Name, meta.ResourceType) + require.Equal(t, baseURL+"/Users/"+id, meta.Location) + require.Equal(t, created, meta.Created) + require.Equal(t, updated, meta.LastModified) + }) + + t.Run("normalizes the timestamps to UTC", func(t *testing.T) { + meta := NewMeta(baseURL, ResourceTypeUser).For(resource{ + id: id, + created: created.In(time.FixedZone("MDT", -6*60*60)), + updated: updated.In(time.FixedZone("MDT", -6*60*60)), + }) + + require.Equal(t, time.UTC, meta.Created.Location()) + require.Equal(t, time.UTC, meta.LastModified.Location()) + require.True(t, meta.Created.Equal(created)) + require.True(t, meta.LastModified.Equal(updated)) + }) + + t.Run("leaves the receiver untouched", func(t *testing.T) { + collection := NewMeta(baseURL, ResourceTypeUser) + + first := collection.For(resource{id: "first"}) + second := collection.For(resource{id: "second"}) - require.Equal(t, ResourceTypeServiceProviderConfig, meta.ResourceType) - require.Equal(t, "http://localhost:9999/scim/v2/ServiceProviderConfig", meta.Location) + require.Equal(t, baseURL+"/Users/first", first.Location) + require.Equal(t, baseURL+"/Users/second", second.Location) + require.Equal(t, baseURL+"/Users", collection.Location) + require.Zero(t, collection.Created) }) } func TestMeta(t *testing.T) { t.Run("serializes to JSON correctly", func(t *testing.T) { body, err := json.Marshal(Meta{ - ResourceType: ResourceTypeServiceProviderConfig, + ResourceType: "ServiceProviderConfig", Location: "http://localhost:9999/scim/v2/ServiceProviderConfig", }) @@ -31,9 +89,9 @@ func TestMeta(t *testing.T) { }) t.Run("omits the location when it is empty", func(t *testing.T) { - body, err := json.Marshal(Meta{ResourceType: ResourceTypeServiceProviderConfig}) + body, err := json.Marshal(Meta{ResourceType: "Example"}) require.NoError(t, err) - require.JSONEq(t, `{"resourceType": "ServiceProviderConfig"}`, string(body)) + require.JSONEq(t, `{"resourceType": "Example"}`, string(body)) }) } diff --git a/internal/api/scim/core/resource.go b/internal/api/scim/core/resource.go new file mode 100644 index 000000000..8cfe8a727 --- /dev/null +++ b/internal/api/scim/core/resource.go @@ -0,0 +1,9 @@ +package core + +import "time" + +type Resource interface { + ResourceID() string + ResourceType() ResourceType + Timestamps() (created, updated time.Time) +} diff --git a/internal/api/scim/core/resource_type.go b/internal/api/scim/core/resource_type.go new file mode 100644 index 000000000..234a9eee8 --- /dev/null +++ b/internal/api/scim/core/resource_type.go @@ -0,0 +1,31 @@ +package core + +var ( + ResourceTypeServiceProviderConfig = ResourceType{ + Name: "ServiceProviderConfig", + Endpoint: "/ServiceProviderConfig", + } + + ResourceTypeUser = ResourceType{ + Name: "User", + Endpoint: "/Users", + } +) + +type ResourceTypeName string + +type ResourceType struct { + Name ResourceTypeName + Endpoint string +} + +func (r ResourceType) Meta(baseURL string) Meta { + return Meta{ + ResourceType: r.Name, + Location: r.Location(baseURL), + } +} + +func (r ResourceType) Location(baseURL string) string { + return baseURL + r.Endpoint +} diff --git a/internal/api/scim/core/resource_type_test.go b/internal/api/scim/core/resource_type_test.go new file mode 100644 index 000000000..1abc4b9ae --- /dev/null +++ b/internal/api/scim/core/resource_type_test.go @@ -0,0 +1,24 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResourceType(t *testing.T) { + r := ResourceType{Name: "Resource", Endpoint: "/Resources"} + + t.Run("Meta", func(t *testing.T) { + baseURL := "http://localhost:9999/scim/v2" + + t.Run("locates a resource that is its own endpoint", func(t *testing.T) { + meta := r.Meta(baseURL) + + require.Equal(t, ResourceTypeName("Resource"), meta.ResourceType) + require.Equal(t, baseURL+"/Resources", meta.Location) + require.Zero(t, meta.Created) + require.Zero(t, meta.LastModified) + }) + }) +} diff --git a/internal/api/scim/core/schemas.go b/internal/api/scim/core/schemas.go index 128b2ea71..026d49c6b 100644 --- a/internal/api/scim/core/schemas.go +++ b/internal/api/scim/core/schemas.go @@ -6,8 +6,5 @@ const ( schemaCore = schemaRoot + ":core:2.0" SchemaServiceProviderConfig SchemaURI = schemaCore + ":ServiceProviderConfig" -) - -const ( - ResourceTypeServiceProviderConfig ResourceTypeName = "ServiceProviderConfig" + SchemaUser SchemaURI = schemaCore + ":User" ) diff --git a/internal/api/scim/core/service_provider_config.go b/internal/api/scim/core/service_provider_config.go index 26c64da94..c684c6c9a 100644 --- a/internal/api/scim/core/service_provider_config.go +++ b/internal/api/scim/core/service_provider_config.go @@ -1,49 +1,5 @@ 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"` @@ -61,10 +17,9 @@ func NewServiceProviderConfig(baseURL string, schemes ...*AuthenticationScheme) if schemes == nil { schemes = []*AuthenticationScheme{} } - return &ServiceProviderConfig{ Schemas: []SchemaURI{SchemaServiceProviderConfig}, AuthenticationSchemes: schemes, - Meta: NewMeta(baseURL, ResourceTypeServiceProviderConfig, EndpointServiceProviderConfig), + Meta: ResourceTypeServiceProviderConfig.Meta(baseURL), } } diff --git a/internal/api/scim/core/service_provider_config_test.go b/internal/api/scim/core/service_provider_config_test.go index 03ff2dca9..552cce38b 100644 --- a/internal/api/scim/core/service_provider_config_test.go +++ b/internal/api/scim/core/service_provider_config_test.go @@ -23,8 +23,8 @@ func TestNewServiceProviderConfig(t *testing.T) { config := NewServiceProviderConfig(baseURL) - require.Equal(t, ResourceTypeServiceProviderConfig, config.Meta.ResourceType) - require.Equal(t, baseURL+EndpointServiceProviderConfig, config.Meta.Location) + require.Equal(t, ResourceTypeServiceProviderConfig.Name, config.Meta.ResourceType) + require.Equal(t, baseURL+"/ServiceProviderConfig", config.Meta.Location) }) t.Run("supports none of the optional protocol features", func(t *testing.T) { diff --git a/internal/api/scim/core/user.go b/internal/api/scim/core/user.go new file mode 100644 index 000000000..ca58472cd --- /dev/null +++ b/internal/api/scim/core/user.go @@ -0,0 +1,24 @@ +package core + +type Email struct { + Value string `json:"value"` + Primary bool `json:"primary"` +} + +// Name holds the components of the user's name, per RFC 7643, Section 4.1.1. +type Name struct { + Formatted string `json:"formatted,omitempty"` + FamilyName string `json:"familyName,omitempty"` + GivenName string `json:"givenName,omitempty"` + MiddleName string `json:"middleName,omitempty"` +} + +// User is the core User resource defined in RFC 7643, Section 4.1. +type User struct { + Schemas []SchemaURI `json:"schemas"` + ID string `json:"id"` + UserName string `json:"userName"` + Name Name `json:"name,omitzero"` + Emails []Email `json:"emails,omitempty"` + Meta Meta `json:"meta"` +} diff --git a/internal/api/scim/core/user_test.go b/internal/api/scim/core/user_test.go new file mode 100644 index 000000000..080cde140 --- /dev/null +++ b/internal/api/scim/core/user_test.go @@ -0,0 +1,72 @@ +package core + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestUser(t *testing.T) { + created := time.Date(2026, 7, 21, 19, 41, 41, 0, time.UTC) + lastModified := time.Date(2026, 7, 22, 8, 12, 3, 0, time.UTC) + + user := User{ + Schemas: []SchemaURI{SchemaUser}, + ID: "2819c223-7f76-453a-919d-413861904646", + UserName: "bjensen@example.com", + Emails: []Email{{Value: "bjensen@example.com", Primary: true}}, + Meta: Meta{ + ResourceType: ResourceTypeUser.Name, + Created: created, + LastModified: lastModified, + Location: "http://localhost:9999/scim/v2/Users/2819c223-7f76-453a-919d-413861904646", + }, + } + + t.Run("serializes to JSON correctly", func(t *testing.T) { + body, err := json.Marshal(user) + + require.NoError(t, err) + require.JSONEq(t, `{ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + "emails": [{"value": "bjensen@example.com", "primary": true}], + "meta": { + "resourceType": "User", + "created": "2026-07-21T19:41:41Z", + "lastModified": "2026-07-22T08:12:03Z", + "location": "http://localhost:9999/scim/v2/Users/2819c223-7f76-453a-919d-413861904646" + } + }`, string(body)) + }) + + t.Run("omits emails when there are none", func(t *testing.T) { + user.Emails = nil + + body, err := json.Marshal(user) + + require.NoError(t, err) + require.NotContains(t, string(body), "emails") + }) + + t.Run("omits the name when there is none", func(t *testing.T) { + user.Name = Name{} + + body, err := json.Marshal(user) + + require.NoError(t, err) + require.NotContains(t, string(body), `"name"`) + }) + + t.Run("serializes only the name components that are set", func(t *testing.T) { + user.Name = Name{FamilyName: "Jensen", GivenName: "Barbara"} + + body, err := json.Marshal(user) + + require.NoError(t, err) + require.Contains(t, string(body), `"name":{"familyName":"Jensen","givenName":"Barbara"}`) + }) +} diff --git a/internal/api/scim/mapper.go b/internal/api/scim/mapper.go new file mode 100644 index 000000000..f1433e6ba --- /dev/null +++ b/internal/api/scim/mapper.go @@ -0,0 +1,34 @@ +package scim + +import ( + "github.com/supabase/auth/internal/api/scim/core" + "github.com/supabase/auth/internal/models" +) + +type Mapper[TIn, TOut any] interface { + MapFrom(in TIn) TOut +} + +type UserMapper struct { + baseURL string +} + +func NewUserMapper(baseURL string) UserMapper { + return UserMapper{baseURL: baseURL} +} + +func (m UserMapper) MapFrom(in *models.ProvisionedUser) *core.User { + return &core.User{ + Schemas: []core.SchemaURI{core.SchemaUser}, + ID: in.ResourceID(), + UserName: in.UserName(), + Name: core.Name{ + Formatted: in.Claim("name"), + FamilyName: in.Claim("family_name"), + GivenName: in.Claim("given_name"), + MiddleName: in.Claim("middle_name"), + }, + Emails: []core.Email{{Value: in.PrimaryEmail(), Primary: true}}, + Meta: in.ResourceType().Meta(m.baseURL).For(in), + } +} diff --git a/internal/api/scim/mapper_test.go b/internal/api/scim/mapper_test.go new file mode 100644 index 000000000..c037760c9 --- /dev/null +++ b/internal/api/scim/mapper_test.go @@ -0,0 +1,124 @@ +package scim + +import ( + "testing" + "time" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/api/scim/core" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" +) + +func TestUserMapper(t *testing.T) { + id := uuid.Must(uuid.FromString("2819c223-7f76-453a-919d-413861904646")) + createdAt := time.Date(2026, 7, 21, 19, 41, 41, 0, time.UTC) + updatedAt := time.Date(2026, 7, 22, 8, 12, 3, 0, time.UTC) + + newModel := func(email string, claims map[string]interface{}) *models.ProvisionedUser { + in := &models.ProvisionedUser{ + User: &models.User{ + ID: id, + Email: storage.NullString(email), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + } + if claims != nil { + in.Identity = &models.Identity{IdentityData: claims} + } + return in + } + + mapper := NewUserMapper("http://localhost:9999/scim/v2") + + t.Run("maps a user onto the core User resource", func(t *testing.T) { + user := mapper.MapFrom(newModel("bjensen@example.com", nil)) + + require.Equal(t, []core.SchemaURI{core.SchemaUser}, user.Schemas) + require.Equal(t, id.String(), user.ID) + require.Equal(t, "bjensen@example.com", user.UserName) + require.Equal(t, []core.Email{{Value: "bjensen@example.com", Primary: true}}, user.Emails) + require.Equal(t, core.ResourceTypeUser.Name, user.Meta.ResourceType) + }) + + t.Run("builds the location from the base URL", func(t *testing.T) { + user := NewUserMapper("https://auth.example.com/scim/v2").MapFrom(newModel("bjensen@example.com", nil)) + + require.Equal(t, "https://auth.example.com/scim/v2/Users/"+id.String(), user.Meta.Location) + }) + + t.Run("normalizes the timestamps to UTC", func(t *testing.T) { + model := newModel("bjensen@example.com", nil) + model.CreatedAt = createdAt.In(time.FixedZone("MDT", -6*60*60)) + + user := mapper.MapFrom(model) + + require.Equal(t, time.UTC, user.Meta.Created.Location()) + require.True(t, user.Meta.Created.Equal(createdAt)) + require.Equal(t, updatedAt, user.Meta.LastModified) + }) + + t.Run("prefers the email the provider supplied over the user record", func(t *testing.T) { + user := mapper.MapFrom(newModel("stale@example.com", map[string]interface{}{ + "email": "bjensen@example.com", + })) + + require.Equal(t, "bjensen@example.com", user.UserName) + require.Equal(t, []core.Email{{Value: "bjensen@example.com", Primary: true}}, user.Emails) + }) + + t.Run("falls back to the user record when the provider supplied no email", func(t *testing.T) { + user := mapper.MapFrom(newModel("bjensen@example.com", map[string]interface{}{ + "sub": id.String(), + })) + + require.Equal(t, "bjensen@example.com", user.UserName) + require.Equal(t, []core.Email{{Value: "bjensen@example.com", Primary: true}}, user.Emails) + }) + + t.Run("prefers preferred_username for the userName", func(t *testing.T) { + user := mapper.MapFrom(newModel("bjensen@example.com", map[string]interface{}{ + "preferred_username": "bjensen", + "email": "bjensen@example.com", + })) + + require.Equal(t, "bjensen", user.UserName) + require.Equal(t, []core.Email{{Value: "bjensen@example.com", Primary: true}}, user.Emails) + }) + + t.Run("maps the name components the provider supplied", func(t *testing.T) { + user := mapper.MapFrom(newModel("bjensen@example.com", map[string]interface{}{ + "name": "Ms. Barbara Jane Jensen, III", + "family_name": "Jensen", + "given_name": "Barbara", + "middle_name": "Jane", + })) + + require.Equal(t, core.Name{ + Formatted: "Ms. Barbara Jane Jensen, III", + FamilyName: "Jensen", + GivenName: "Barbara", + MiddleName: "Jane", + }, user.Name) + }) + + t.Run("omits the name when the provider supplied no components", func(t *testing.T) { + require.Equal(t, core.Name{}, mapper.MapFrom(newModel("bjensen@example.com", nil)).Name) + require.Equal(t, core.Name{}, mapper.MapFrom(newModel("bjensen@example.com", map[string]interface{}{ + "sub": id.String(), + })).Name) + }) + + t.Run("ignores claims that are not strings", func(t *testing.T) { + user := mapper.MapFrom(newModel("bjensen@example.com", map[string]interface{}{ + "email": 12345, + "given_name": []string{"Barbara"}, + "family_name": "Jensen", + })) + + require.Equal(t, "bjensen@example.com", user.UserName) + require.Equal(t, core.Name{FamilyName: "Jensen"}, user.Name) + }) +} diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go index d4b479caa..38abbe19e 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -7,18 +7,31 @@ import ( "github.com/supabase/auth/internal/api/scim/core" "github.com/supabase/auth/internal/api/scim/protocol" "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/observability" + "github.com/supabase/auth/internal/storage" ) const BasePath = "/scim/v2" +type TokenExtractor func(r *http.Request) (string, error) + type Server struct { + db *storage.Connection + extract TokenExtractor + users Mapper[*models.ProvisionedUser, *core.User] serviceProviderConfig *core.ServiceProviderConfig } -func NewServer(config *conf.GlobalConfiguration) *Server { +func NewServer(config *conf.GlobalConfiguration, db *storage.Connection, extract TokenExtractor) *Server { + baseURL := strings.TrimRight(config.API.ExternalURL, "/") + BasePath + return &Server{ + db: db, + extract: extract, + users: NewUserMapper(baseURL), serviceProviderConfig: core.NewServiceProviderConfig( - strings.TrimRight(config.API.ExternalURL, "/")+BasePath, + baseURL, core.NewOAuthBearerToken().AsPrimary(), ), } @@ -40,6 +53,11 @@ func (srv *Server) NotFound(w http.ResponseWriter, r *http.Request) error { return protocol.SendError(w, http.StatusNotFound, "", "Endpoint or resource does not exist") } +func (srv *Server) internalError(w http.ResponseWriter, r *http.Request, err error) error { + observability.LogEntrySetField(r, "error", err.Error()) + return protocol.SendError(w, http.StatusInternalServerError, "", "Internal server error") +} + func list[T any](w http.ResponseWriter, r *http.Request, resources []T) error { if r.URL.Query().Get("filter") != "" { return protocol.SendError(w, 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 773638bcd..8c1580441 100644 --- a/internal/api/scim/server_test.go +++ b/internal/api/scim/server_test.go @@ -15,18 +15,18 @@ import ( //go:embed testdata/* var fixtures embed.FS +func newServerFor(externalURL string) *Server { + return NewServer(&conf.GlobalConfiguration{ + API: conf.APIConfiguration{ExternalURL: externalURL}, + }, nil, nil) +} + func testFixture(t *testing.T, file string) string { data, err := fixtures.ReadFile("testdata/" + file) require.NoError(t, err) return string(data) } -func newServerFor(externalURL string) *Server { - return NewServer(&conf.GlobalConfiguration{ - API: conf.APIConfiguration{ExternalURL: externalURL}, - }) -} - func TestServer(t *testing.T) { srv := newServerFor("http://localhost:9999") require.NotNil(t, srv) diff --git a/internal/api/scim/users.go b/internal/api/scim/users.go new file mode 100644 index 000000000..e8b09ca82 --- /dev/null +++ b/internal/api/scim/users.go @@ -0,0 +1,31 @@ +package scim + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/gofrs/uuid" + "github.com/supabase/auth/internal/api/scim/protocol" + "github.com/supabase/auth/internal/api/shared" + "github.com/supabase/auth/internal/models" +) + +func (srv *Server) UserByID(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + id, err := uuid.FromString(chi.URLParam(r, "id")) + if err != nil { + return srv.NotFound(w, r) + } + + provider := shared.GetSSOProvider(ctx) + user, err := provider.FindProvisionedUserByID(srv.db.WithContext(ctx), id) + if err != nil { + if models.IsNotFoundError(err) { + return srv.NotFound(w, r) + } + return srv.internalError(w, r, err) + } + + return protocol.Send(w, http.StatusOK, srv.users.MapFrom(user)) +} diff --git a/internal/api/scim_users_test.go b/internal/api/scim_users_test.go new file mode 100644 index 000000000..39e21b5ac --- /dev/null +++ b/internal/api/scim_users_test.go @@ -0,0 +1,315 @@ +package api + +import ( + "fmt" + "math/rand/v2" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + 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/models" + "github.com/supabase/auth/internal/storage" +) + +const ( + minTenantUsers = 2 + maxTenantUsers = 5 +) + +type tenant struct { + provider *models.SSOProvider + users []*models.User + token string + domain string +} + +type SCIMUsersTestSuite struct { + suite.Suite + API *API + Config *conf.GlobalConfiguration + TenantA *tenant + TenantB *tenant +} + +func TestSCIMUsers(t *testing.T) { + api, config, err := setupAPIForTestWithCallback(func(cfg *conf.GlobalConfiguration, _ *storage.Connection) { + if cfg != nil { + cfg.Experimental.ScimEnabled = true + } + }) + require.NoError(t, err) + defer api.db.Close() + + suite.Run(t, &SCIMUsersTestSuite{API: api, Config: config}) +} + +func (ts *SCIMUsersTestSuite) SetupTest() { + require.NoError(ts.T(), models.TruncateAll(ts.API.db)) + + ts.TenantA = seedSCIMTenant(ts.T(), ts.API.db, "example.com") + ts.TenantB = seedSCIMTenant(ts.T(), ts.API.db, "example.org") +} + +func (ts *SCIMUsersTestSuite) get(id, token string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "/scim/v2/Users/"+id, nil) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, r) + return w +} + +func (ts *SCIMUsersTestSuite) TestGetUser() { + ts.Run("returns the user that belongs to the token's provider", func() { + user := ts.TenantA.users[0] + + w := ts.get(user.ID.String(), ts.TenantA.token) + + require.Equal(ts.T(), http.StatusOK, w.Code) + require.Equal(ts.T(), scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(ts.T(), fmt.Sprintf(`{ + "schemas": [%q], + "id": %q, + "userName": %q, + "emails": [{"value": %q, "primary": true}], + "meta": { + "resourceType": "User", + "created": %q, + "lastModified": %q, + "location": "%s/scim/v2/Users/%s" + } + }`, + scimCore.SchemaUser, + user.ID, + user.GetEmail(), + user.GetEmail(), + user.CreatedAt.UTC().Format(time.RFC3339Nano), + user.UpdatedAt.UTC().Format(time.RFC3339Nano), + ts.Config.API.ExternalURL, user.ID, + ), w.Body.String()) + }) + + ts.Run("returns every user the tenant provisioned", func() { + require.Greater(ts.T(), len(ts.TenantA.users), 1) + + for _, user := range ts.TenantA.users { + w := ts.get(user.ID.String(), ts.TenantA.token) + + require.Equal(ts.T(), http.StatusOK, w.Code, "user %s", user.ID) + require.Contains(ts.T(), w.Body.String(), user.GetEmail()) + } + }) + + ts.Run("scopes each provider to its own users", func() { + require.Equal(ts.T(), http.StatusOK, ts.get(ts.TenantA.users[0].ID.String(), ts.TenantA.token).Code) + require.Equal(ts.T(), http.StatusNotFound, ts.get(ts.TenantA.users[0].ID.String(), ts.TenantB.token).Code) + + require.Equal(ts.T(), http.StatusOK, ts.get(ts.TenantB.users[0].ID.String(), ts.TenantB.token).Code) + require.Equal(ts.T(), http.StatusNotFound, ts.get(ts.TenantB.users[0].ID.String(), ts.TenantA.token).Code) + }) + + ts.Run("omits name when the provider has no attribute mapping", func() { + user := ts.TenantA.users[0] + + w := ts.get(user.ID.String(), ts.TenantA.token) + + require.Equal(ts.T(), http.StatusOK, w.Code) + require.NotContains(ts.T(), w.Body.String(), `"name"`) + require.Contains(ts.T(), w.Body.String(), fmt.Sprintf(`"userName":%q`, user.GetEmail())) + }) + + ts.Run("keeps custom claims out of the response", func() { + user := seedSCIMUser(ts.T(), ts.API.db, ts.TenantA.provider, "custom@"+ts.TenantA.domain, map[string]interface{}{ + "custom_claims": map[string]interface{}{"department": "engineering"}, + }) + + w := ts.get(user.ID.String(), ts.TenantA.token) + + require.Equal(ts.T(), http.StatusOK, w.Code) + require.NotContains(ts.T(), w.Body.String(), "department") + require.NotContains(ts.T(), w.Body.String(), "engineering") + }) + + ts.Run("identifies the resource by user id, not by the NameID", func() { + opaque := seedSCIMUser(ts.T(), ts.API.db, ts.TenantA.provider, "persistent@"+ts.TenantA.domain, map[string]interface{}{ + "sub": uuid.Must(uuid.NewV4()).String(), + }) + + w := ts.get(opaque.ID.String(), ts.TenantA.token) + + require.Equal(ts.T(), http.StatusOK, w.Code) + require.Contains(ts.T(), w.Body.String(), fmt.Sprintf(`"id":%q`, opaque.ID)) + }) + + ts.Run("maps the attributes when the provider has an attribute mapping", func() { + user := seedSCIMUser(ts.T(), ts.API.db, ts.TenantA.provider, "stale@example.com", map[string]interface{}{ + "email": "bjensen@example.com", + "preferred_username": "bjensen", + "name": "Ms. Barbara Jane Jensen, III", + "family_name": "Jensen", + "given_name": "Barbara", + "middle_name": "Jane", + }) + + w := ts.get(user.ID.String(), ts.TenantA.token) + + require.Equal(ts.T(), http.StatusOK, w.Code) + require.JSONEq(ts.T(), fmt.Sprintf(`{ + "schemas": [%q], + "id": %q, + "userName": "bjensen", + "name": { + "formatted": "Ms. Barbara Jane Jensen, III", + "familyName": "Jensen", + "givenName": "Barbara", + "middleName": "Jane" + }, + "emails": [{"value": "bjensen@example.com", "primary": true}], + "meta": { + "resourceType": "User", + "created": %q, + "lastModified": %q, + "location": "%s/scim/v2/Users/%s" + } + }`, + scimCore.SchemaUser, + user.ID, + user.CreatedAt.UTC().Format(time.RFC3339Nano), + user.UpdatedAt.UTC().Format(time.RFC3339Nano), + ts.Config.API.ExternalURL, user.ID, + ), w.Body.String()) + }) +} + +func (ts *SCIMUsersTestSuite) TestTenantIsolation() { + ts.Run("hides every user belonging to another provider", func() { + for _, user := range ts.TenantB.users { + w := ts.get(user.ID.String(), ts.TenantA.token) + + require.Equal(ts.T(), http.StatusNotFound, w.Code, "user %s", user.ID) + require.Equal(ts.T(), scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(ts.T(), w.Body.String(), scimProtocol.SchemaError) + } + }) + + ts.Run("returns the same 404 for an unknown id", func() { + unknown := ts.get(uuid.Must(uuid.NewV4()).String(), ts.TenantA.token) + other := ts.get(ts.TenantB.users[0].ID.String(), ts.TenantA.token) + + require.Equal(ts.T(), http.StatusNotFound, unknown.Code) + require.Equal(ts.T(), other.Body.String(), unknown.Body.String()) + }) + + ts.Run("returns 404 for a malformed id", func() { + w := ts.get("not-a-uuid", ts.TenantA.token) + + require.Equal(ts.T(), http.StatusNotFound, w.Code) + require.Equal(ts.T(), scimProtocol.MediaType, w.Header().Get("Content-Type")) + }) +} + +func (ts *SCIMUsersTestSuite) TestAuthentication() { + ts.Run("requires a bearer token", func() { + w := ts.get(ts.TenantA.users[0].ID.String(), "") + + require.Equal(ts.T(), http.StatusUnauthorized, w.Code) + require.Equal(ts.T(), scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Equal(ts.T(), "Bearer", w.Header().Get("WWW-Authenticate")) + require.Contains(ts.T(), w.Body.String(), scimProtocol.SchemaError) + }) + + ts.Run("rejects an unknown token", func() { + w := ts.get(ts.TenantA.users[0].ID.String(), uuid.Must(uuid.NewV4()).String()) + + require.Equal(ts.T(), http.StatusUnauthorized, w.Code) + require.Equal(ts.T(), "Bearer", w.Header().Get("WWW-Authenticate")) + }) +} + +func (ts *SCIMUsersTestSuite) TestRejectsADisabledProvider() { + disabled := true + ts.TenantB.provider.Disabled = &disabled + require.NoError(ts.T(), ts.API.db.Update(ts.TenantB.provider)) + + w := ts.get(ts.TenantB.users[0].ID.String(), ts.TenantB.token) + + require.Equal(ts.T(), http.StatusForbidden, w.Code) + require.Equal(ts.T(), scimProtocol.MediaType, w.Header().Get("Content-Type")) +} + +func (ts *SCIMUsersTestSuite) TestStaysHiddenWhenTheFeatureFlagIsOff() { + disabled, _, err := setupAPIForTest() + require.NoError(ts.T(), err) + + r := httptest.NewRequest(http.MethodGet, "/scim/v2/Users/"+ts.TenantA.users[0].ID.String(), nil) + r.Header.Set("Authorization", "Bearer "+ts.TenantA.token) + w := httptest.NewRecorder() + disabled.handler.ServeHTTP(w, r) + + require.Equal(ts.T(), http.StatusNotFound, w.Code) + require.Equal(ts.T(), "application/json", w.Header().Get("Content-Type")) + require.NotContains(ts.T(), w.Body.String(), scimProtocol.SchemaError) +} + +func seedSCIMTenant(t *testing.T, conn *storage.Connection, domain string) *tenant { + t.Helper() + + id := uuid.Must(uuid.NewV4()).String() + provider := &models.SSOProvider{ + SAMLProvider: models.SAMLProvider{ + EntityID: "https://example.com/saml/metadata/" + id, + MetadataXML: "", + }, + SSODomains: []models.SSODomain{{Domain: domain}}, + } + token := uuid.Must(uuid.NewV4()).String() + provider.UpdateSCIMToken(token) + require.NoError(t, conn.Eager().Create(provider)) + + count := minTenantUsers + rand.IntN(maxTenantUsers-minTenantUsers+1) + + users := make([]*models.User, 0, count) + for range count { + email := uuid.Must(uuid.NewV4()).String() + "@" + domain + users = append(users, seedSCIMUser(t, conn, provider, email)) + } + + return &tenant{provider: provider, users: users, token: token, domain: domain} +} + +func seedSCIMUser(t *testing.T, conn *storage.Connection, provider *models.SSOProvider, email string, extraClaims ...map[string]interface{}) *models.User { + t.Helper() + + user, err := models.NewUser("", email, "", "authenticated", nil) + require.NoError(t, err) + user.IsSSOUser = true + require.NoError(t, conn.Create(user)) + + claims := map[string]interface{}{ + "iss": provider.SAMLProvider.EntityID, + "sub": email, + "email": email, + "email_verified": true, + "phone_verified": false, + "custom_claims": map[string]interface{}{}, + } + for _, extra := range extraClaims { + for key, value := range extra { + claims[key] = value + } + } + + identity, err := models.NewIdentity(user, provider.ProviderType(), claims) + require.NoError(t, err) + require.NoError(t, conn.Create(identity)) + + return user +} diff --git a/internal/api/shared/context.go b/internal/api/shared/context.go index 81bfd4752..be6572063 100644 --- a/internal/api/shared/context.go +++ b/internal/api/shared/context.go @@ -7,66 +7,70 @@ import ( ) // ContextKey is the type for context keys to avoid collisions -type ContextKey string +type ContextKey[T any] string -func (c ContextKey) String() string { +func (c ContextKey[T]) String() string { return "gotrue api context key " + string(c) } +func (c ContextKey[T]) Get(ctx context.Context) T { + var zero T + if ctx == nil { + return zero + } + obj := ctx.Value(c) + if obj == nil { + return zero + } + return obj.(T) +} + +func (c ContextKey[T]) With(ctx context.Context, t T) context.Context { + return context.WithValue(ctx, c, t) +} + // Context keys used across packages const ( - UserKey ContextKey = "user" - SessionKey ContextKey = "session" - OAuthServerClientKey ContextKey = "oauth_server_client" + UserKey ContextKey[*models.User] = "user" + SessionKey ContextKey[*models.Session] = "session" + OAuthServerClientKey ContextKey[*models.OAuthServerClient] = "oauth_server_client" + SSOProviderKey ContextKey[*models.SSOProvider] = "sso_provider" ) // GetUser reads the user from the context - shared implementation func GetUser(ctx context.Context) *models.User { - if ctx == nil { - return nil - } - obj := ctx.Value(UserKey) - if obj == nil { - return nil - } - return obj.(*models.User) + return UserKey.Get(ctx) } // WithUser adds the user to the context - shared implementation func WithUser(ctx context.Context, u *models.User) context.Context { - return context.WithValue(ctx, UserKey, u) + return UserKey.With(ctx, u) } // GetSession reads the session from the context - shared implementation func GetSession(ctx context.Context) *models.Session { - if ctx == nil { - return nil - } - obj := ctx.Value(SessionKey) - if obj == nil { - return nil - } - return obj.(*models.Session) + return SessionKey.Get(ctx) } // WithSession adds the session to the context - shared implementation func WithSession(ctx context.Context, s *models.Session) context.Context { - return context.WithValue(ctx, SessionKey, s) + return SessionKey.With(ctx, s) } // WithOAuthServerClient adds an OAuth server client to the context func WithOAuthServerClient(ctx context.Context, client *models.OAuthServerClient) context.Context { - return context.WithValue(ctx, OAuthServerClientKey, client) + return OAuthServerClientKey.With(ctx, client) } // GetOAuthServerClient retrieves an OAuth server client from the context func GetOAuthServerClient(ctx context.Context) *models.OAuthServerClient { - if ctx == nil { - return nil - } - obj := ctx.Value(OAuthServerClientKey) - if obj == nil { - return nil - } - return obj.(*models.OAuthServerClient) + return OAuthServerClientKey.Get(ctx) +} + +func GetSSOProvider(ctx context.Context) *models.SSOProvider { + return SSOProviderKey.Get(ctx) +} + +func WithSSOProvider(ctx context.Context, s *models.SSOProvider) context.Context { + return SSOProviderKey.With(ctx, s) } diff --git a/internal/models/identity.go b/internal/models/identity.go index 1f5ee5f85..9c7af20fb 100644 --- a/internal/models/identity.go +++ b/internal/models/identity.go @@ -93,6 +93,18 @@ func FindIdentityByIdAndProvider(tx *storage.Connection, providerId, provider st return identity, nil } +// FindIdentityByUserIDAndProvider searches for the identity linking a user to a provider. +func FindIdentityByUserIDAndProvider(tx *storage.Connection, userID uuid.UUID, provider string) (*Identity, error) { + identity := &Identity{} + if err := tx.Q().Where("user_id = ? AND provider = ?", userID, provider).First(identity); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, IdentityNotFoundError{} + } + return nil, errors.Wrap(err, "error finding identity") + } + return identity, nil +} + // FindIdentitiesByUserID returns all identities associated to a user ID. func FindIdentitiesByUserID(tx *storage.Connection, userID uuid.UUID) ([]*Identity, error) { identities := []*Identity{} diff --git a/internal/models/provisioned_user.go b/internal/models/provisioned_user.go new file mode 100644 index 000000000..6ade5a687 --- /dev/null +++ b/internal/models/provisioned_user.go @@ -0,0 +1,46 @@ +package models + +import ( + "time" + + "github.com/supabase/auth/internal/api/scim/core" +) + +type ProvisionedUser struct { + *User + Identity *Identity +} + +func (u *ProvisionedUser) PrimaryEmail() string { + if email := u.Claim("email"); email != "" { + return email + } + return u.GetEmail() +} + +func (u *ProvisionedUser) UserName() string { + if userName := u.Claim("preferred_username"); userName != "" { + return userName + } + return u.PrimaryEmail() +} + +func (u *ProvisionedUser) Claim(key string) string { + if u.Identity == nil { + return "" + } + value, _ := u.Identity.IdentityData[key].(string) + return value +} + +func (u *ProvisionedUser) ResourceID() string { + return u.ID.String() +} + +func (u *ProvisionedUser) ResourceType() core.ResourceType { + return core.ResourceTypeUser +} + +func (u *ProvisionedUser) Timestamps() (created, updated time.Time) { + return u.CreatedAt, u.UpdatedAt +} diff --git a/internal/models/sso.go b/internal/models/sso.go index 3a5be7d97..8bfe10c86 100644 --- a/internal/models/sso.go +++ b/internal/models/sso.go @@ -1,8 +1,10 @@ package models import ( + "crypto/sha256" "database/sql" "database/sql/driver" + "encoding/hex" "encoding/json" "net/url" "reflect" @@ -23,6 +25,8 @@ type SSOProvider struct { SAMLProvider SAMLProvider `has_one:"saml_providers" fk_id:"sso_provider_id" json:"saml,omitempty"` SSODomains []SSODomain `has_many:"sso_domains" fk_id:"sso_provider_id" json:"domains"` + SCIMTokenHash *string `db:"scim_token_hash" json:"-"` + CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } @@ -39,6 +43,47 @@ func (p SSOProvider) Type() string { return "saml" } +func (p *SSOProvider) UpdateSCIMToken(token string) { + hash := toSHA256(token) + p.SCIMTokenHash = &hash +} + +func (p *SSOProvider) ProviderType() string { + return SSOProviderType(p.ID) +} + +// SSOProviderType is the identities.provider value for users of an SSO provider. +func SSOProviderType(id uuid.UUID) string { + return "sso:" + id.String() +} + +func (p *SSOProvider) FindUserByID(tx *storage.Connection, id uuid.UUID) (*User, error) { + return FindUserByIDAndSSOProviderID(tx, id, p.ID) +} + +func (p *SSOProvider) FindIdentityByUserID(tx *storage.Connection, userID uuid.UUID) (*Identity, error) { + return FindIdentityByUserIDAndProvider(tx, userID, p.ProviderType()) +} + +func (p *SSOProvider) FindProvisionedUserByID(tx *storage.Connection, id uuid.UUID) (*ProvisionedUser, error) { + user, err := p.FindUserByID(tx, id) + if err != nil { + return nil, err + } + + identity, err := p.FindIdentityByUserID(tx, user.ID) + if err != nil { + return nil, err + } + + return &ProvisionedUser{User: user, Identity: identity}, nil +} + +func toSHA256(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + type SAMLAttribute struct { Name string `json:"name,omitempty"` Names []string `json:"names,omitempty"` @@ -222,6 +267,20 @@ func FindSSOProviderByResourceID(tx *storage.Connection, id string) (*SSOProvide return &ssoProvider, nil } +func FindSSOProviderBySCIMToken(tx *storage.Connection, token string) (*SSOProvider, error) { + var ssoProvider SSOProvider + + if err := tx.Q().Where("scim_token_hash = ?", toSHA256(token)).First(&ssoProvider); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, SSOProviderNotFoundError{} + } + + return nil, errors.Wrap(err, "error finding SSO provider by SCIM token") + } + + return &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 523ad614c..cd06cc196 100644 --- a/internal/models/sso_test.go +++ b/internal/models/sso_test.go @@ -469,3 +469,80 @@ func (ts *SSOTestSuite) TestFindSSOProviderByResourceID() { require.Nil(ts.T(), got) } } + +func (ts *SSOTestSuite) TestUpdateSCIMToken() { + hashes := map[string]string{ + "scim_test_token": "dcbcd9ffd696ae1f2ee0f035fa17680d78175020a5fa1aadc758dbd681e0fe1d", + "scim_rotated_token": "289adb37f8946571bb4aea1e663281126c7f2d84d929ff09429fcaa1eb3f27bf", + } + + provider := &SSOProvider{ + SAMLProvider: SAMLProvider{ + EntityID: "https://example.com/saml/metadata/", + MetadataXML: "", + }, + } + require.Nil(ts.T(), provider.SCIMTokenHash) + + for token, hash := range hashes { + provider.UpdateSCIMToken(token) + require.NotNil(ts.T(), provider.SCIMTokenHash) + require.Equal(ts.T(), hash, *provider.SCIMTokenHash) + } +} + +func (ts *SSOTestSuite) TestFindSSOProviderBySCIMToken() { + token := "scim_test_token" + provider := &SSOProvider{ + SAMLProvider: SAMLProvider{ + EntityID: "https://example.com/saml/metadata/1", + MetadataXML: "", + }, + } + + provider.UpdateSCIMToken(token) + require.NoError(ts.T(), ts.db.Eager().Create(provider)) + + withoutToken := &SSOProvider{ + SAMLProvider: SAMLProvider{ + EntityID: "https://example.com/saml/metadata/2", + MetadataXML: "", + }, + } + require.NoError(ts.T(), ts.db.Eager().Create(withoutToken)) + + ts.Run("resolves the provider that owns the token", func() { + found, err := FindSSOProviderBySCIMToken(ts.db, token) + + require.NoError(ts.T(), err) + require.Equal(ts.T(), provider.ID, found.ID) + }) + + ts.Run("an unknown token resolves nothing", func() { + found, err := FindSSOProviderBySCIMToken(ts.db, "scim_unknown_token") + + require.Nil(ts.T(), found) + require.True(ts.T(), IsNotFoundError(err)) + }) + + ts.Run("an empty token does not match a provider without one", func() { + found, err := FindSSOProviderBySCIMToken(ts.db, "") + + require.Nil(ts.T(), found) + require.True(ts.T(), IsNotFoundError(err)) + }) + + ts.Run("rotation stops the previous token from resolving", func() { + newToken := "scim_rotated_token" + provider.UpdateSCIMToken(newToken) + require.NoError(ts.T(), ts.db.Update(provider)) + + found, err := FindSSOProviderBySCIMToken(ts.db, newToken) + require.NoError(ts.T(), err) + require.Equal(ts.T(), provider.ID, found.ID) + + found, err = FindSSOProviderBySCIMToken(ts.db, token) + require.Nil(ts.T(), found) + require.True(ts.T(), IsNotFoundError(err)) + }) +} diff --git a/internal/models/user.go b/internal/models/user.go index f88a9729b..acf25c4e0 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -709,6 +709,25 @@ func FindUserByID(tx *storage.Connection, id uuid.UUID) (*User, error) { return findUser(tx, "instance_id = ? and id = ?", uuid.Nil, id) } +// FindUserByIDAndSSOProviderID finds a user matching the provided SSO provider ID and ID +func FindUserByIDAndSSOProviderID(tx *storage.Connection, id, ssoProviderID uuid.UUID) (*User, error) { + obj := &User{} + // Skip findUser's eager loading + query := tx.Q().Where( + "instance_id = ? and id = ? and deleted_at is null and is_sso_user = true and id in (select user_id from identities where provider = ?)", + uuid.Nil, id, SSOProviderType(ssoProviderID), + ) + + if err := query.First(obj); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, UserNotFoundError{} + } + return nil, errors.Wrap(err, "error finding user") + } + + return obj, nil +} + // FindUserWithRefreshToken finds a user from the provided refresh token. If // forUpdate is set to true, then the SELECT statement used by the query has // the form SELECT ... FOR UPDATE SKIP LOCKED. This means that a FOR UPDATE diff --git a/internal/models/user_test.go b/internal/models/user_test.go index 502392605..9db56ff0f 100644 --- a/internal/models/user_test.go +++ b/internal/models/user_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -856,3 +857,77 @@ func (ts *UserTestSuite) TestAuthenticate() { }) } } + +func (ts *UserTestSuite) TestFindUserByIDAndSSOProviderID() { + providerA := uuid.Must(uuid.NewV4()) + providerB := uuid.Must(uuid.NewV4()) + + createSSOUser := func(email string, providerID uuid.UUID) *User { + u, err := NewUser("", email, "", "authenticated", nil) + require.NoError(ts.T(), err) + u.IsSSOUser = true + require.NoError(ts.T(), ts.db.Create(u)) + + identity, err := NewIdentity(u, "sso:"+providerID.String(), map[string]interface{}{ + "sub": u.ID.String(), + "email": email, + }) + require.NoError(ts.T(), err) + require.NoError(ts.T(), ts.db.Create(identity)) + + return u + } + + userA := createSSOUser("a@example.com", providerA) + userB := createSSOUser("b@example.com", providerB) + + ts.Run("finds a user belonging to the provider", func() { + found, err := FindUserByIDAndSSOProviderID(ts.db, userA.ID, providerA) + + require.NoError(ts.T(), err) + require.Equal(ts.T(), userA.ID, found.ID) + }) + + ts.Run("does not find a user belonging to another provider", func() { + found, err := FindUserByIDAndSSOProviderID(ts.db, userB.ID, providerA) + + require.Nil(ts.T(), found) + require.True(ts.T(), IsNotFoundError(err)) + }) + + ts.Run("does not find an unknown id", func() { + found, err := FindUserByIDAndSSOProviderID(ts.db, uuid.Must(uuid.NewV4()), providerA) + + require.Nil(ts.T(), found) + require.True(ts.T(), IsNotFoundError(err)) + }) + + ts.Run("does not find a non-SSO user", func() { + u, err := NewUser("", "plain@example.com", "", "authenticated", nil) + require.NoError(ts.T(), err) + require.NoError(ts.T(), ts.db.Create(u)) + + identity, err := NewIdentity(u, "sso:"+providerA.String(), map[string]interface{}{ + "sub": u.ID.String(), + "email": "plain@example.com", + }) + require.NoError(ts.T(), err) + require.NoError(ts.T(), ts.db.Create(identity)) + + found, err := FindUserByIDAndSSOProviderID(ts.db, u.ID, providerA) + + require.Nil(ts.T(), found) + require.True(ts.T(), IsNotFoundError(err)) + }) + + ts.Run("does not find a soft deleted user", func() { + deletedAt := time.Now() + userA.DeletedAt = &deletedAt + require.NoError(ts.T(), ts.db.Update(userA)) + + found, err := FindUserByIDAndSSOProviderID(ts.db, userA.ID, providerA) + + require.Nil(ts.T(), found) + require.True(ts.T(), IsNotFoundError(err)) + }) +} diff --git a/migrations/20260731000000_add_scim_token_hash_to_sso_providers.up.sql b/migrations/20260731000000_add_scim_token_hash_to_sso_providers.up.sql new file mode 100644 index 000000000..fd8afeadf --- /dev/null +++ b/migrations/20260731000000_add_scim_token_hash_to_sso_providers.up.sql @@ -0,0 +1,9 @@ +-- Holds the SHA-256 hex digest of the provider's SCIM token. +/* auth_migration: 20260731000000 */ +alter table only {{ index .Options "Namespace" }}.sso_providers + add column if not exists scim_token_hash text null; + +/* auth_migration: 20260731000000 */ +create unique index if not exists sso_providers_scim_token_hash_idx + on {{ index .Options "Namespace" }}.sso_providers (scim_token_hash) + where scim_token_hash is not null;