diff --git a/internal/api/api.go b/internal/api/api.go index beebb26c0..b32a725b3 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -457,7 +457,9 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.Get("/ServiceProviderConfig", api.scim.ServiceProviderConfig) r.Get("/ResourceTypes", api.scim.ResourceTypes) + r.Get("/ResourceTypes/{id}", api.scim.ResourceTypeByID) r.Get("/Schemas", api.scim.Schemas) + r.Get("/Schemas/{id}", api.scim.SchemaByID) }) }) diff --git a/internal/api/scim/core/attribute.go b/internal/api/scim/core/attribute.go new file mode 100644 index 000000000..ae89b9596 --- /dev/null +++ b/internal/api/scim/core/attribute.go @@ -0,0 +1,115 @@ +package core + +// AttributeType is the data type of an attribute, per RFC 7643, Section 7. +type AttributeType string + +const ( + TypeString AttributeType = "string" + TypeBoolean AttributeType = "boolean" + TypeDecimal AttributeType = "decimal" + TypeInteger AttributeType = "integer" + TypeDateTime AttributeType = "dateTime" + TypeReference AttributeType = "reference" + TypeComplex AttributeType = "complex" +) + +// Mutability states when an attribute may be (re)defined. +type Mutability string + +const ( + MutabilityReadOnly Mutability = "readOnly" + MutabilityReadWrite Mutability = "readWrite" + MutabilityImmutable Mutability = "immutable" + MutabilityWriteOnly Mutability = "writeOnly" +) + +// Returned states when an attribute is included in a response. +type Returned string + +const ( + ReturnedAlways Returned = "always" + ReturnedNever Returned = "never" + ReturnedDefault Returned = "default" + ReturnedRequest Returned = "request" +) + +// Uniqueness states how the service provider enforces uniqueness. +type Uniqueness string + +const ( + UniquenessNone Uniqueness = "none" + UniquenessServer Uniqueness = "server" + UniquenessGlobal Uniqueness = "global" +) + +// The reference types of RFC 7643, Section 7 that are not resource types. +const ( + ReferenceExternal = "external" + ReferenceURI = "uri" +) + +// Attribute describes one attribute of a schema, per RFC 7643, Section 7. +type Attribute struct { + Name string `json:"name"` + Type AttributeType `json:"type"` + MultiValued bool `json:"multiValued"` + Description string `json:"description"` + Required bool `json:"required"` + CanonicalValues []string `json:"canonicalValues,omitempty"` + CaseExact bool `json:"caseExact"` + Mutability Mutability `json:"mutability"` + Returned Returned `json:"returned"` + Uniqueness Uniqueness `json:"uniqueness"` + ReferenceTypes []string `json:"referenceTypes,omitempty"` + SubAttributes []*Attribute `json:"subAttributes,omitempty"` +} + +func NewAttribute(name string, attributeType AttributeType, description string) *Attribute { + return &Attribute{ + Name: name, + Type: attributeType, + Description: description, + Mutability: MutabilityReadWrite, + Returned: ReturnedDefault, + Uniqueness: UniquenessNone, + } +} + +func (a *Attribute) AsRequired() *Attribute { + a.Required = true + return a +} + +func (a *Attribute) AsMultiValued() *Attribute { + a.MultiValued = true + return a +} + +func (a *Attribute) AsCaseExact() *Attribute { + a.CaseExact = true + return a +} + +// Suggesting sets "canonicalValues", the values a client may send for this +// attribute, e.g. "work" and "home". +func (a *Attribute) Suggesting(values ...string) *Attribute { + a.CanonicalValues = values + return a +} + +// Referencing sets "referenceTypes", the resource types a reference attribute +// may point at, either by name or as ReferenceExternal or ReferenceURI. +func (a *Attribute) Referencing(referenceTypes ...string) *Attribute { + a.ReferenceTypes = referenceTypes + return a +} + +func (a *Attribute) UniqueOn(uniqueness Uniqueness) *Attribute { + a.Uniqueness = uniqueness + return a +} + +func (a *Attribute) With(subAttributes ...*Attribute) *Attribute { + a.SubAttributes = subAttributes + return a +} diff --git a/internal/api/scim/core/attribute_test.go b/internal/api/scim/core/attribute_test.go new file mode 100644 index 000000000..802228bc5 --- /dev/null +++ b/internal/api/scim/core/attribute_test.go @@ -0,0 +1,20 @@ +package core + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAttribute(t *testing.T) { + t.Run("suggests the canonical values a client may send", func(t *testing.T) { + attribute := NewAttribute("type", TypeString, "A label indicating the attribute's function."). + Suggesting("work", "home", "other") + + body, err := json.Marshal(attribute) + + require.NoError(t, err) + require.Contains(t, string(body), `"canonicalValues":["work","home","other"]`) + }) +} diff --git a/internal/api/scim/core/authentication_scheme.go b/internal/api/scim/core/authentication_scheme.go new file mode 100644 index 000000000..fd6c28639 --- /dev/null +++ b/internal/api/scim/core/authentication_scheme.go @@ -0,0 +1,35 @@ +package core + +type AuthenticationSchemeType string + +// The authentication scheme types of RFC 7643, Section 5. +const ( + AuthenticationSchemeOAuth AuthenticationSchemeType = "oauth" + AuthenticationSchemeOAuth2 AuthenticationSchemeType = "oauth2" + AuthenticationSchemeOAuthBearerToken AuthenticationSchemeType = "oauthbearertoken" + AuthenticationSchemeHTTPBasic AuthenticationSchemeType = "httpbasic" + AuthenticationSchemeHTTPDigest AuthenticationSchemeType = "httpdigest" +) + +type AuthenticationScheme struct { + Type AuthenticationSchemeType `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + SpecURI string `json:"specUri,omitempty"` + DocumentationURI string `json:"documentationUri,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..633d32747 100644 --- a/internal/api/scim/core/core.go +++ b/internal/api/scim/core/core.go @@ -1,8 +1,14 @@ // Package core implements the SCIM 2.0 core schema defined in RFC 7643. package core +import "strings" + // SchemaURI identifies a SCIM schema type SchemaURI string -// ResourceTypeName names a resource type -type ResourceTypeName string +// Join appends segment to base with exactly one separator. RFC 7643, Section 6 +// defines an endpoint as relative to the base URL, so a segment spelled either +// "Users" or "/Users" must produce the same URL. +func Join(base, segment string) string { + return strings.TrimSuffix(base, "/") + "/" + strings.TrimPrefix(segment, "/") +} 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/kind.go b/internal/api/scim/core/kind.go new file mode 100644 index 000000000..bd4f4d94f --- /dev/null +++ b/internal/api/scim/core/kind.go @@ -0,0 +1,27 @@ +package core + +// ResourceTypeName is the name of a resource type, per RFC 7643, Section 6. It +// is the value that the "meta.resourceType" attribute of every resource refers +// to. +type ResourceTypeName string + +// Kind names a collection of resources and the endpoint it is served from. A +// Kind is not itself a SCIM resource: only User and Group are served from +// /ResourceTypes, while the rest exist solely to locate their own resources. +type Kind struct { + Name ResourceTypeName + Endpoint string +} + +var ( + KindGroup = Kind{Name: "Group", Endpoint: "/Groups"} + KindResourceType = Kind{Name: "ResourceType", Endpoint: "/ResourceTypes"} + KindSchema = Kind{Name: "Schema", Endpoint: "/Schemas"} + KindServiceProviderConfig = Kind{Name: "ServiceProviderConfig", Endpoint: "/ServiceProviderConfig"} + KindUser = Kind{Name: "User", Endpoint: "/Users"} +) + +// Location returns the URL of the collection under baseURL. +func (k Kind) Location(baseURL string) string { + return Join(baseURL, k.Endpoint) +} diff --git a/internal/api/scim/core/kind_test.go b/internal/api/scim/core/kind_test.go new file mode 100644 index 000000000..9aa7c7a36 --- /dev/null +++ b/internal/api/scim/core/kind_test.go @@ -0,0 +1,19 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestKindLocation(t *testing.T) { + baseURL := "http://localhost:9999/scim/v2" + + t.Run("locates the collection under the base URL", func(t *testing.T) { + require.Equal(t, baseURL+"/Users", KindUser.Location(baseURL)) + }) + + t.Run("does not double the separator when the base URL ends in a slash", func(t *testing.T) { + require.Equal(t, baseURL+"/Users", KindUser.Location(baseURL+"/")) + }) +} diff --git a/internal/api/scim/core/meta.go b/internal/api/scim/core/meta.go index a47e4a4b3..4666a4e20 100644 --- a/internal/api/scim/core/meta.go +++ b/internal/api/scim/core/meta.go @@ -1,14 +1,41 @@ package core +import "time" + // Meta is the resource metadata common attribute defined in RFC 7643, Section 3.1. type Meta struct { ResourceType ResourceTypeName `json:"resourceType"` + Created time.Time `json:"created,omitzero"` + LastModified time.Time `json:"lastModified,omitzero"` Location string `json:"location,omitempty"` + // Version is the resource's entity-tag, and is only set by providers whose + // ServiceProviderConfig advertises etag support. + Version string `json:"version,omitempty"` } -func NewMeta(baseURL string, resourceType ResourceTypeName, endpoint string) Meta { +// NewMeta builds the metadata of a resource that is its own endpoint, such as +// the ServiceProviderConfig of RFC 7643, Section 5. +func NewMeta(baseURL string, kind Kind) Meta { return Meta{ - ResourceType: resourceType, - Location: baseURL + endpoint, + ResourceType: kind.Name, + Location: kind.Location(baseURL), } } + +// NewMetaFor builds the metadata of one resource of a collection, locating it +// at the collection endpoint followed by its id, e.g. /Users/{id}. +func NewMetaFor(baseURL string, kind Kind, r Resource) Meta { + created, updated := r.Timestamps() + return NewMetaForID(baseURL, kind, r.ResourceID(), created, updated) +} + +// NewMetaForID builds a collection member's metadata from its id and timestamps +// directly, for callers that hold those values without a Resource to wrap them, +// such as a storage adapter mapping a row to a resource. +func NewMetaForID(baseURL string, kind Kind, id string, created, updated time.Time) Meta { + meta := NewMeta(baseURL, kind) + meta.Location = Join(meta.Location, id) + meta.Created, meta.LastModified = created.UTC(), updated.UTC() + + return meta +} diff --git a/internal/api/scim/core/meta_test.go b/internal/api/scim/core/meta_test.go index 4b7383bd7..367f4d505 100644 --- a/internal/api/scim/core/meta_test.go +++ b/internal/api/scim/core/meta_test.go @@ -3,23 +3,54 @@ 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) 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, KindServiceProviderConfig) + + require.Equal(t, KindServiceProviderConfig.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 := NewMetaFor(baseURL, KindUser, resource{ + id: id, + created: created, + updated: updated, + }) - require.Equal(t, ResourceTypeServiceProviderConfig, meta.ResourceType) - require.Equal(t, "http://localhost:9999/scim/v2/ServiceProviderConfig", meta.Location) + require.Equal(t, KindUser.Name, meta.ResourceType) + require.Equal(t, baseURL+"/Users/"+id, meta.Location) + require.Equal(t, created, meta.Created) + require.Equal(t, updated, meta.LastModified) }) + } 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", }) @@ -29,11 +60,4 @@ func TestMeta(t *testing.T) { "location": "http://localhost:9999/scim/v2/ServiceProviderConfig" }`, string(body)) }) - - t.Run("omits the location when it is empty", func(t *testing.T) { - body, err := json.Marshal(Meta{ResourceType: ResourceTypeServiceProviderConfig}) - - require.NoError(t, err) - require.JSONEq(t, `{"resourceType": "ServiceProviderConfig"}`, string(body)) - }) } diff --git a/internal/api/scim/core/resource.go b/internal/api/scim/core/resource.go new file mode 100644 index 000000000..12b8a2e33 --- /dev/null +++ b/internal/api/scim/core/resource.go @@ -0,0 +1,8 @@ +package core + +import "time" + +type Resource interface { + ResourceID() string + 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..3eda20c38 --- /dev/null +++ b/internal/api/scim/core/resource_type.go @@ -0,0 +1,53 @@ +package core + +import "time" + +// SchemaExtension is a schema that extends a resource type, per RFC 7643, +// Section 6. Both of its attributes are required, so neither is omitted. +type SchemaExtension struct { + Schema SchemaURI `json:"schema"` + Required bool `json:"required"` +} + +// ResourceType is the resource type metadata defined in RFC 7643, Section 6. +type ResourceType struct { + Schemas []SchemaURI `json:"schemas,omitempty"` + ID ResourceTypeName `json:"id,omitempty"` + Name ResourceTypeName `json:"name"` + Description string `json:"description,omitempty"` + Endpoint string `json:"endpoint"` + Schema SchemaURI `json:"schema,omitempty"` + SchemaExtensions []SchemaExtension `json:"schemaExtensions,omitempty"` + Meta Meta `json:"meta,omitzero"` +} + +func NewResourceType(baseURL string, kind Kind, schema *Schema) *ResourceType { + resourceType := &ResourceType{ + Schemas: []SchemaURI{SchemaResourceType}, + ID: kind.Name, + Name: kind.Name, + Description: schema.Description, + Endpoint: kind.Endpoint, + Schema: schema.ID, + } + resourceType.Meta = NewMetaFor(baseURL, KindResourceType, resourceType) + + return resourceType +} + +// Extend declares schemas that extend this resource type, e.g. the enterprise +// User of RFC 7643, Section 4.3. +func (r *ResourceType) Extend(extensions ...SchemaExtension) *ResourceType { + r.SchemaExtensions = append(r.SchemaExtensions, extensions...) + return r +} + +// Kind returns the kind of resource this resource type defines, which locates +// the resources served from it. +func (r ResourceType) Kind() Kind { + return Kind{Name: r.Name, Endpoint: r.Endpoint} +} + +func (r *ResourceType) ResourceID() string { return string(r.ID) } + +func (r *ResourceType) Timestamps() (created, updated time.Time) { return } 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..c75f2c330 --- /dev/null +++ b/internal/api/scim/core/resource_type_test.go @@ -0,0 +1,41 @@ +package core + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResourceType(t *testing.T) { + baseURL := "http://localhost:9999/scim/v2" + + t.Run("NewResourceType", func(t *testing.T) { + schema := NewSchema(baseURL, SchemaUser, KindUser.Name).Describe("User Account") + + resourceType := NewResourceType(baseURL, KindUser, schema) + + t.Run("takes its identity and description from the schema", func(t *testing.T) { + require.Equal(t, KindUser.Name, resourceType.ID) + require.Equal(t, KindUser.Name, resourceType.Name) + require.Equal(t, "User Account", resourceType.Description) + require.Equal(t, SchemaUser, resourceType.Schema) + }) + + t.Run("locates itself under the ResourceTypes endpoint", func(t *testing.T) { + require.Equal(t, KindResourceType.Name, resourceType.Meta.ResourceType) + require.Equal(t, baseURL+"/ResourceTypes/User", resourceType.Meta.Location) + }) + + t.Run("declares the schema extensions it was given", func(t *testing.T) { + extended := NewResourceType(baseURL, KindUser, schema). + Extend(SchemaExtension{Schema: SchemaEnterpriseUser, Required: true}) + + body, err := json.Marshal(extended) + + require.NoError(t, err) + require.Contains(t, string(body), + `"schemaExtensions":[{"schema":"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User","required":true}]`) + }) + }) +} diff --git a/internal/api/scim/core/schema.go b/internal/api/scim/core/schema.go new file mode 100644 index 000000000..2ab8b58c1 --- /dev/null +++ b/internal/api/scim/core/schema.go @@ -0,0 +1,38 @@ +package core + +import "time" + +// Schema is the schema definition resource of RFC 7643, Section 7. +type Schema struct { + Schemas []SchemaURI `json:"schemas"` + ID SchemaURI `json:"id"` + Name ResourceTypeName `json:"name"` + Description string `json:"description"` + Attributes []*Attribute `json:"attributes"` + Meta Meta `json:"meta"` +} + +func NewSchema(baseURL string, id SchemaURI, name ResourceTypeName) *Schema { + schema := &Schema{ + Schemas: []SchemaURI{SchemaSchema}, + ID: id, + Name: name, + } + schema.Meta = NewMetaFor(baseURL, KindSchema, schema) + + return schema +} + +func (s *Schema) Describe(description string) *Schema { + s.Description = description + return s +} + +func (s *Schema) With(attributes ...*Attribute) *Schema { + s.Attributes = attributes + return s +} + +func (s *Schema) ResourceID() string { return string(s.ID) } + +func (s *Schema) Timestamps() (created, updated time.Time) { return } diff --git a/internal/api/scim/core/schemas.go b/internal/api/scim/core/schemas.go index 128b2ea71..454183286 100644 --- a/internal/api/scim/core/schemas.go +++ b/internal/api/scim/core/schemas.go @@ -2,12 +2,15 @@ package core // The schema URIs of RFC 7643 const ( - schemaRoot = "urn:ietf:params:scim:schemas" - schemaCore = schemaRoot + ":core:2.0" + schemaRoot = "urn:ietf:params:scim:schemas" + schemaCore = schemaRoot + ":core:2.0" + schemaExtension = schemaRoot + ":extension" - SchemaServiceProviderConfig SchemaURI = schemaCore + ":ServiceProviderConfig" -) + SchemaEnterpriseUser SchemaURI = schemaExtension + ":enterprise:2.0:User" -const ( - ResourceTypeServiceProviderConfig ResourceTypeName = "ServiceProviderConfig" + SchemaGroup SchemaURI = schemaCore + ":Group" + SchemaResourceType SchemaURI = schemaCore + ":ResourceType" + SchemaSchema SchemaURI = schemaCore + ":Schema" + SchemaServiceProviderConfig SchemaURI = schemaCore + ":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..c33c35bab 100644 --- a/internal/api/scim/core/service_provider_config.go +++ b/internal/api/scim/core/service_provider_config.go @@ -1,52 +1,9 @@ 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"` + DocumentationURI string `json:"documentationUri,omitempty"` Patch SupportedFeature `json:"patch"` Bulk BulkFeature `json:"bulk"` Filter FilterFeature `json:"filter"` @@ -61,10 +18,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: NewMeta(baseURL, KindServiceProviderConfig), } } diff --git a/internal/api/scim/core/service_provider_config_test.go b/internal/api/scim/core/service_provider_config_test.go index 03ff2dca9..ba5448a3c 100644 --- a/internal/api/scim/core/service_provider_config_test.go +++ b/internal/api/scim/core/service_provider_config_test.go @@ -9,35 +9,6 @@ import ( ) func TestNewServiceProviderConfig(t *testing.T) { - t.Run("advertises the schemes the caller declares", func(t *testing.T) { - scheme := NewOAuthBearerToken().AsPrimary() - - config := NewServiceProviderConfig("", scheme) - - require.Equal(t, []SchemaURI{SchemaServiceProviderConfig}, config.Schemas) - require.Equal(t, []*AuthenticationScheme{scheme}, config.AuthenticationSchemes) - }) - - t.Run("identifies itself with resource metadata", func(t *testing.T) { - baseURL := "http://localhost:9999/scim/v2" - - config := NewServiceProviderConfig(baseURL) - - require.Equal(t, ResourceTypeServiceProviderConfig, config.Meta.ResourceType) - require.Equal(t, baseURL+EndpointServiceProviderConfig, config.Meta.Location) - }) - - t.Run("supports none of the optional protocol features", func(t *testing.T) { - config := NewServiceProviderConfig("") - - assert.False(t, config.Patch.Supported) - assert.False(t, config.Bulk.Supported) - assert.False(t, config.Filter.Supported) - assert.False(t, config.ChangePassword.Supported) - assert.False(t, config.Sort.Supported) - assert.False(t, config.ETag.Supported) - }) - t.Run("serializes authenticationSchemes as an array", func(t *testing.T) { body, err := json.Marshal(NewServiceProviderConfig("")) @@ -56,11 +27,4 @@ func TestAuthenticationScheme(t *testing.T) { assert.Equal(t, "http://www.rfc-editor.org/info/rfc6750", scheme.SpecURI) assert.False(t, scheme.Primary) }) - - t.Run("AsPrimary marks the scheme primary", func(t *testing.T) { - scheme := NewOAuthBearerToken() - - require.Same(t, scheme, scheme.AsPrimary()) - assert.True(t, scheme.Primary) - }) } diff --git a/internal/api/scim/core/user.go b/internal/api/scim/core/user.go new file mode 100644 index 000000000..11c5f25ae --- /dev/null +++ b/internal/api/scim/core/user.go @@ -0,0 +1,26 @@ +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"` + ExternalID string `json:"externalId,omitempty"` + UserName string `json:"userName"` + Name Name `json:"name,omitzero"` + Emails []Email `json:"emails,omitempty"` + Active *bool `json:"active,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..6748165c9 --- /dev/null +++ b/internal/api/scim/core/user_test.go @@ -0,0 +1,61 @@ +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", + ExternalID: "701984", + UserName: "bjensen@example.com", + Name: Name{Formatted: "Ms. Barbara J Jensen", FamilyName: "Jensen", GivenName: "Barbara"}, + Emails: []Email{{Value: "bjensen@example.com", Primary: true}}, + Active: new(true), + Meta: Meta{ + ResourceType: KindUser.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", + "externalId": "701984", + "userName": "bjensen@example.com", + "name": {"formatted": "Ms. Barbara J Jensen", "familyName": "Jensen", "givenName": "Barbara"}, + "emails": [{"value": "bjensen@example.com", "primary": true}], + "active": 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("round-trips a deactivated user", func(t *testing.T) { + var decoded User + require.NoError(t, json.Unmarshal([]byte(`{"userName":"bjensen","active":false}`), &decoded)) + + body, err := json.Marshal(decoded) + + require.NoError(t, err) + require.Contains(t, string(body), `"active":false`) + }) +} diff --git a/internal/api/scim/protocol/error.go b/internal/api/scim/protocol/error.go index fb183692f..568753225 100644 --- a/internal/api/scim/protocol/error.go +++ b/internal/api/scim/protocol/error.go @@ -2,21 +2,37 @@ package protocol import ( "strconv" + + "github.com/supabase/auth/internal/api/scim/core" ) -const SchemaError = "urn:ietf:params:scim:api:messages:2.0:Error" +// ScimType is a detail error keyword from RFC 7644, Table 9. +type ScimType string + +const ( + ScimTypeInvalidFilter ScimType = "invalidFilter" + ScimTypeInvalidPath ScimType = "invalidPath" + ScimTypeInvalidSyntax ScimType = "invalidSyntax" + ScimTypeInvalidValue ScimType = "invalidValue" + ScimTypeInvalidVers ScimType = "invalidVers" + ScimTypeMutability ScimType = "mutability" + ScimTypeNoTarget ScimType = "noTarget" + ScimTypeSensitive ScimType = "sensitive" + ScimTypeTooMany ScimType = "tooMany" + ScimTypeUniqueness ScimType = "uniqueness" +) // 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"` + Schemas []core.SchemaURI `json:"schemas"` + ScimType ScimType `json:"scimType,omitempty"` + Detail string `json:"detail,omitempty"` + Status string `json:"status"` } -func NewError(status int, scimType string, detail string) *Error { +func NewError(status int, scimType ScimType, detail string) *Error { return &Error{ - Schemas: []string{SchemaError}, + Schemas: []core.SchemaURI{SchemaError}, ScimType: scimType, Detail: detail, Status: strconv.Itoa(status), diff --git a/internal/api/scim/protocol/error_test.go b/internal/api/scim/protocol/error_test.go index aede262a8..dea702512 100644 --- a/internal/api/scim/protocol/error_test.go +++ b/internal/api/scim/protocol/error_test.go @@ -15,33 +15,9 @@ func TestNewError(t *testing.T) { require.NoError(t, err) assert.JSONEq(t, `{ - "schemas": [ - "urn:ietf:params:scim:api:messages:2.0:Error" - ], + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "404", "detail": "Endpoint or resource does not exist" }`, string(body)) }) - - t.Run("includes the scimType when one is given", func(t *testing.T) { - body, err := json.Marshal(NewError(http.StatusBadRequest, "invalidValue", "A required value was missing")) - - require.NoError(t, err) - assert.JSONEq(t, `{ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "scimType": "invalidValue", - "detail": "A required value was missing", - "status": "400" - }`, string(body)) - }) - - t.Run("omits the optional attributes when they are empty", func(t *testing.T) { - body, err := json.Marshal(NewError(http.StatusBadRequest, "", "")) - - require.NoError(t, err) - assert.JSONEq(t, `{ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status": "400" - }`, string(body)) - }) } diff --git a/internal/api/scim/protocol/list_response.go b/internal/api/scim/protocol/list_response.go index 972229f71..6142b9f1e 100644 --- a/internal/api/scim/protocol/list_response.go +++ b/internal/api/scim/protocol/list_response.go @@ -1,25 +1,31 @@ package protocol -const SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" +import "github.com/supabase/auth/internal/api/scim/core" 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"` + Schemas []core.SchemaURI `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] { +// NewPage returns one window of a larger collection. RFC 7644, Section 3.4.2 +// requires totalResults to count every match, not only the resources returned. +func NewPage[T any](resources []T, startIndex, totalResults int) *ListResponse[T] { if resources == nil { resources = []T{} } - n := len(resources) return &ListResponse[T]{ - Schemas: []string{SchemaListResponse}, - TotalResults: n, - StartIndex: 1, - ItemsPerPage: n, + Schemas: []core.SchemaURI{SchemaListResponse}, + TotalResults: totalResults, + StartIndex: startIndex, + ItemsPerPage: len(resources), Resources: resources, } } + +// NewListResponse returns a collection that is served whole, never paged. +func NewListResponse[T any](resources []T) *ListResponse[T] { + return NewPage(resources, 1, len(resources)) +} diff --git a/internal/api/scim/protocol/list_response_test.go b/internal/api/scim/protocol/list_response_test.go index 6c4de87bd..b73df03c5 100644 --- a/internal/api/scim/protocol/list_response_test.go +++ b/internal/api/scim/protocol/list_response_test.go @@ -7,47 +7,32 @@ import ( "github.com/stretchr/testify/require" ) -const emptyListResponse = `{ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "totalResults": 0, - "startIndex": 1, - "itemsPerPage": 0, - "Resources": [] -}` - func TestNewListResponse(t *testing.T) { - for _, tc := range []struct { - name string - resources []string - expected string - }{ - { - name: "nil resources marshal to an empty array", - resources: nil, - expected: emptyListResponse, - }, - { - name: "empty resources marshal to an empty array", - resources: []string{}, - expected: emptyListResponse, - }, - { - name: "populated resources are counted", - resources: []string{"a", "b"}, - expected: `{ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "totalResults": 2, - "startIndex": 1, - "itemsPerPage": 2, - "Resources": ["a", "b"] - }`, - }, - } { - t.Run(tc.name, func(t *testing.T) { - body, err := json.Marshal(NewListResponse(tc.resources)) + t.Run("populated resources are counted", func(t *testing.T) { + body, err := json.Marshal(NewListResponse([]string{"a", "b"})) + + require.NoError(t, err) + require.JSONEq(t, `{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 2, + "startIndex": 1, + "itemsPerPage": 2, + "Resources": ["a", "b"] + }`, string(body)) + }) +} + +func TestNewPage(t *testing.T) { + t.Run("counts every match, not just the resources on the page", func(t *testing.T) { + body, err := json.Marshal(NewPage([]string{"c", "d"}, 3, 9)) - require.NoError(t, err) - require.JSONEq(t, tc.expected, string(body)) - }) - } + require.NoError(t, err) + require.JSONEq(t, `{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "totalResults": 9, + "startIndex": 3, + "itemsPerPage": 2, + "Resources": ["c", "d"] + }`, string(body)) + }) } diff --git a/internal/api/scim/protocol/messages.go b/internal/api/scim/protocol/messages.go new file mode 100644 index 000000000..69d2d2555 --- /dev/null +++ b/internal/api/scim/protocol/messages.go @@ -0,0 +1,15 @@ +package protocol + +import "github.com/supabase/auth/internal/api/scim/core" + +// The message URIs of RFC 7644 +const ( + messagesRoot = "urn:ietf:params:scim:api:messages:2.0" + + SchemaBulkRequest core.SchemaURI = messagesRoot + ":BulkRequest" + SchemaBulkResponse core.SchemaURI = messagesRoot + ":BulkResponse" + SchemaError core.SchemaURI = messagesRoot + ":Error" + SchemaListResponse core.SchemaURI = messagesRoot + ":ListResponse" + SchemaPatchOp core.SchemaURI = messagesRoot + ":PatchOp" + SchemaSearchRequest core.SchemaURI = messagesRoot + ":SearchRequest" +) diff --git a/internal/api/scim/protocol/page.go b/internal/api/scim/protocol/page.go new file mode 100644 index 000000000..dd1a7fb79 --- /dev/null +++ b/internal/api/scim/protocol/page.go @@ -0,0 +1,61 @@ +package protocol + +import ( + "fmt" + "net/url" + "strconv" +) + +const ( + // DefaultCount is the page size used when a client asks for no particular + // one. RFC 7644, Section 3.4.2.4 leaves that maximum to the provider. + DefaultCount = 100 + + // MaxCount is the largest page this provider will return. Asking for more + // is allowed; the RFC only forbids returning more than was asked for. + MaxCount = 100 +) + +// Page is the window of a collection a client asked for, per RFC 7644, +// Section 3.4.2.4. StartIndex counts resources from 1, not pages. +type Page struct { + StartIndex int + Count int +} + +// ParsePage reads the pagination parameters from a query string. Unrecognized +// parameters are ignored, as Section 3.4.2 requires. +func ParsePage(values url.Values) (Page, error) { + startIndex, err := intParam(values, "startIndex", 1) + if err != nil { + return Page{}, err + } + + count, err := intParam(values, "count", DefaultCount) + if err != nil { + return Page{}, err + } + + return Page{ + StartIndex: max(startIndex, 1), + Count: min(max(count, 0), MaxCount), + }, nil +} + +// Offset is the number of resources to skip to reach this page. +func (p Page) Offset() int { + return p.StartIndex - 1 +} + +func intParam(values url.Values, name string, fallback int) (int, error) { + raw := values.Get(name) + if raw == "" { + return fallback, nil + } + + value, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("%q must be an integer", name) + } + return value, nil +} diff --git a/internal/api/scim/protocol/page_test.go b/internal/api/scim/protocol/page_test.go new file mode 100644 index 000000000..d35166a48 --- /dev/null +++ b/internal/api/scim/protocol/page_test.go @@ -0,0 +1,55 @@ +package protocol + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePage(t *testing.T) { + for _, tc := range []struct { + name string + query string + startIndex int + count int + }{ + { + name: "defaults to the whole first page when the client asks for nothing", + query: "", + startIndex: 1, + count: DefaultCount, + }, + { + name: "honours the window the client asked for", + query: "startIndex=11&count=10", + startIndex: 11, + count: 10, + }, + { + name: "caps a count larger than the provider is willing to return", + query: "count=5000", + startIndex: 1, + count: MaxCount, + }, + } { + t.Run(tc.name, func(t *testing.T) { + values, err := url.ParseQuery(tc.query) + require.NoError(t, err) + + page, err := ParsePage(values) + + require.NoError(t, err) + assert.Equal(t, tc.startIndex, page.StartIndex) + assert.Equal(t, tc.count, page.Count) + }) + } +} + +func TestPageOffset(t *testing.T) { + t.Run("converts the 1-based start index into a 0-based offset", func(t *testing.T) { + require.Equal(t, 0, Page{StartIndex: 1}.Offset()) + require.Equal(t, 10, Page{StartIndex: 11}.Offset()) + }) +} diff --git a/internal/api/scim/protocol/protocol.go b/internal/api/scim/protocol/protocol.go index 7e3f4fbff..2dc9276aa 100644 --- a/internal/api/scim/protocol/protocol.go +++ b/internal/api/scim/protocol/protocol.go @@ -13,6 +13,6 @@ func Send(w http.ResponseWriter, status int, obj any) error { return shared.JSON(w).ContentType(MediaType).Status(status).Send(obj) } -func SendError(w http.ResponseWriter, status int, scimType string, detail string) error { +func SendError(w http.ResponseWriter, status int, scimType ScimType, detail string) error { return Send(w, status, NewError(status, scimType, detail)) } diff --git a/internal/api/scim/server.go b/internal/api/scim/server.go index 4de38e4ce..ef9f08699 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -2,8 +2,9 @@ package scim import ( "net/http" - "strings" + "net/url" + "github.com/go-chi/chi/v5" "github.com/supabase/auth/internal/api/scim/core" "github.com/supabase/auth/internal/api/scim/protocol" "github.com/supabase/auth/internal/conf" @@ -12,15 +13,24 @@ import ( const BasePath = "/scim/v2" type Server struct { + baseURL string serviceProviderConfig *core.ServiceProviderConfig + resourceTypes []*core.ResourceType + schemas []*core.Schema } func NewServer(config *conf.GlobalConfiguration) *Server { + baseURL := core.Join(config.API.ExternalURL, BasePath) + userSchema := newUserSchema(baseURL) + return &Server{ + baseURL: baseURL, serviceProviderConfig: core.NewServiceProviderConfig( - strings.TrimRight(config.API.ExternalURL, "/")+BasePath, + baseURL, core.NewOAuthBearerToken().AsPrimary(), ), + resourceTypes: []*core.ResourceType{core.NewResourceType(baseURL, core.KindUser, userSchema)}, + schemas: []*core.Schema{userSchema}, } } @@ -29,20 +39,68 @@ func (srv *Server) ServiceProviderConfig(w http.ResponseWriter, r *http.Request) } func (srv *Server) ResourceTypes(w http.ResponseWriter, r *http.Request) error { - return list(w, r, []any{}) + return list(w, r, srv.resourceTypes) +} + +func (srv *Server) ResourceTypeByID(w http.ResponseWriter, r *http.Request) error { + return byID(srv, w, r, srv.resourceTypes) } func (srv *Server) Schemas(w http.ResponseWriter, r *http.Request) error { - return list(w, r, []any{}) + return list(w, r, srv.schemas) +} + +func (srv *Server) SchemaByID(w http.ResponseWriter, r *http.Request) error { + return byID(srv, w, r, srv.schemas) +} + +func newUserSchema(baseURL string) *core.Schema { + return core. + NewSchema(baseURL, core.SchemaUser, core.KindUser.Name). + Describe("User Account"). + With( + core.NewAttribute("userName", core.TypeString, + "Unique identifier for the User, typically used by the user to directly authenticate to the service provider."). + AsRequired(). + UniqueOn(core.UniquenessServer), + ) +} + +func urlParam(r *http.Request, key string) string { + value := chi.URLParam(r, key) + + if decoded, err := url.PathUnescape(value); err == nil { + return decoded + } + return value } func (srv *Server) NotFound(w http.ResponseWriter, r *http.Request) error { return protocol.SendError(w, http.StatusNotFound, "", "Endpoint or resource does not exist") } +// The discovery collections refuse a filter outright; /Users names the reason. +func rejectFilter(w http.ResponseWriter, r *http.Request, status int, scimType protocol.ScimType) (bool, error) { + if !r.URL.Query().Has("filter") { + return false, nil + } + return true, protocol.SendError(w, status, scimType, "Filtering is not supported on this endpoint") +} + func list[T any](w http.ResponseWriter, r *http.Request, resources []T) error { - if r.URL.Query().Has("filter") { - return protocol.SendError(w, http.StatusForbidden, "", "Filtering is not supported on this endpoint") + if rejected, err := rejectFilter(w, r, http.StatusForbidden, ""); rejected { + return err } return protocol.Send(w, http.StatusOK, protocol.NewListResponse(resources)) } + +func byID[T core.Resource](srv *Server, w http.ResponseWriter, r *http.Request, resources []T) error { + id := urlParam(r, "id") + + for _, resource := range resources { + if resource.ResourceID() == id { + return protocol.Send(w, http.StatusOK, resource) + } + } + return srv.NotFound(w, r) +} diff --git a/internal/api/scim/server_test.go b/internal/api/scim/server_test.go index 773638bcd..bcf0a81c6 100644 --- a/internal/api/scim/server_test.go +++ b/internal/api/scim/server_test.go @@ -1,13 +1,17 @@ package scim import ( + "context" "embed" + "encoding/json" "net/http" "net/http/httptest" "net/url" "testing" + "github.com/go-chi/chi/v5" "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/api/scim/core" "github.com/supabase/auth/internal/api/scim/protocol" "github.com/supabase/auth/internal/conf" ) @@ -15,18 +19,18 @@ import ( //go:embed testdata/* var fixtures embed.FS -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 testFixture(t *testing.T, file string) string { + data, err := fixtures.ReadFile("testdata/" + file) + require.NoError(t, err) + return string(data) +} + func TestServer(t *testing.T) { srv := newServerFor("http://localhost:9999") require.NotNil(t, srv) @@ -49,21 +53,28 @@ func TestServer(t *testing.T) { }) for _, tc := range []struct { - path string - handler func(http.ResponseWriter, *http.Request) error + path, fixture string + id string + list, byID func(http.ResponseWriter, *http.Request) error }{ - {"ResourceTypes", srv.ResourceTypes}, - {"Schemas", srv.Schemas}, + {"ResourceTypes", "resource_type_user.json", string(core.KindUser.Name), srv.ResourceTypes, srv.ResourceTypeByID}, + {"Schemas", "schema_user.json", string(core.SchemaUser), srv.Schemas, srv.SchemaByID}, } { t.Run(tc.path, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, BasePath+"/"+tc.path, nil) w := httptest.NewRecorder() - require.NoError(t, tc.handler(w, r)) + require.NoError(t, tc.list(w, r)) require.Equal(t, http.StatusOK, w.Code) require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) - require.JSONEq(t, testFixture(t, "empty_list_response.json"), w.Body.String()) + + var body protocol.ListResponse[json.RawMessage] + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + + require.Equal(t, 1, body.TotalResults) + require.Len(t, body.Resources, 1) + require.JSONEq(t, testFixture(t, tc.fixture), string(body.Resources[0])) }) t.Run(tc.path+" rejects filter query parameter", func(t *testing.T) { @@ -71,11 +82,33 @@ func TestServer(t *testing.T) { r := httptest.NewRequest(http.MethodGet, BasePath+"/"+tc.path+"?"+filter, nil) w := httptest.NewRecorder() - require.NoError(t, tc.handler(w, r)) + require.NoError(t, tc.list(w, r)) require.Equal(t, http.StatusForbidden, w.Code) require.JSONEq(t, testFixture(t, "filter_forbidden.json"), w.Body.String()) }) + + t.Run(tc.path+"/"+tc.id, func(t *testing.T) { + r := requestWithURLParam(tc.path+"/"+tc.id, "id", tc.id) + w := httptest.NewRecorder() + + require.NoError(t, tc.byID(w, r)) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, testFixture(t, tc.fixture), w.Body.String()) + }) + + t.Run(tc.path+" returns a SCIM 404 for an unknown id", func(t *testing.T) { + r := requestWithURLParam(tc.path+"/Unknown", "id", "Unknown") + w := httptest.NewRecorder() + + require.NoError(t, tc.byID(w, r)) + + require.Equal(t, http.StatusNotFound, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.JSONEq(t, testFixture(t, "not_found.json"), w.Body.String()) + }) } t.Run("NotFound", func(t *testing.T) { @@ -85,7 +118,16 @@ func TestServer(t *testing.T) { require.NoError(t, srv.NotFound(w, r)) require.Equal(t, http.StatusNotFound, w.Code) - require.Equal(t, "application/scim+json", w.Header().Get("Content-Type")) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) require.JSONEq(t, testFixture(t, "not_found.json"), w.Body.String()) }) } + +func requestWithURLParam(path, key, value string) *http.Request { + r := httptest.NewRequest(http.MethodGet, BasePath+"/"+path, nil) + + routeCtx := chi.NewRouteContext() + routeCtx.URLParams.Add(key, value) + + return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, routeCtx)) +} diff --git a/internal/api/scim/testdata/empty_list_response.json b/internal/api/scim/testdata/empty_list_response.json deleted file mode 100644 index d13e376c6..000000000 --- a/internal/api/scim/testdata/empty_list_response.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "schemas": [ - "urn:ietf:params:scim:api:messages:2.0:ListResponse" - ], - "totalResults": 0, - "startIndex": 1, - "itemsPerPage": 0, - "Resources": [] -} diff --git a/internal/api/scim/testdata/resource_type_user.json b/internal/api/scim/testdata/resource_type_user.json new file mode 100644 index 000000000..3a47b4267 --- /dev/null +++ b/internal/api/scim/testdata/resource_type_user.json @@ -0,0 +1,14 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:ResourceType" + ], + "id": "User", + "name": "User", + "description": "User Account", + "endpoint": "/Users", + "schema": "urn:ietf:params:scim:schemas:core:2.0:User", + "meta": { + "resourceType": "ResourceType", + "location": "http://localhost:9999/scim/v2/ResourceTypes/User" + } +} diff --git a/internal/api/scim/testdata/schema_user.json b/internal/api/scim/testdata/schema_user.json new file mode 100644 index 000000000..7d8cfdab7 --- /dev/null +++ b/internal/api/scim/testdata/schema_user.json @@ -0,0 +1,25 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Schema" + ], + "id": "urn:ietf:params:scim:schemas:core:2.0:User", + "name": "User", + "description": "User Account", + "attributes": [ + { + "name": "userName", + "type": "string", + "multiValued": false, + "description": "Unique identifier for the User, typically used by the user to directly authenticate to the service provider.", + "required": true, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "server" + } + ], + "meta": { + "resourceType": "Schema", + "location": "http://localhost:9999/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User" + } +} diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index a6a966823..525b527f2 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -4,11 +4,12 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "github.com/stretchr/testify/require" - scimCore "github.com/supabase/auth/internal/api/scim/core" - scimProtocol "github.com/supabase/auth/internal/api/scim/protocol" + "github.com/supabase/auth/internal/api/scim/core" + "github.com/supabase/auth/internal/api/scim/protocol" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/storage" ) @@ -50,7 +51,7 @@ func TestSCIM(t *testing.T) { api.handler.ServeHTTP(w, r) require.Equal(t, http.StatusNotFound, w.Code) - require.NotContains(t, w.Body.String(), scimProtocol.SchemaError) + require.NotContains(t, w.Body.String(), protocol.SchemaError) }) }) @@ -71,8 +72,8 @@ func TestSCIM(t *testing.T) { api.handler.ServeHTTP(w, r) require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) - require.Contains(t, w.Body.String(), scimCore.SchemaServiceProviderConfig) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), string(core.SchemaServiceProviderConfig)) }) for _, path := range []string{scimResourceTypesPath, scimSchemasPath} { @@ -83,8 +84,8 @@ func TestSCIM(t *testing.T) { api.handler.ServeHTTP(w, r) require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) - require.Contains(t, w.Body.String(), scimProtocol.SchemaListResponse) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), protocol.SchemaListResponse) }) t.Run(path+" rejects filter query parameter", func(t *testing.T) { @@ -95,8 +96,30 @@ func TestSCIM(t *testing.T) { api.handler.ServeHTTP(w, r) require.Equal(t, http.StatusForbidden, w.Code) - require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) - require.Contains(t, w.Body.String(), scimProtocol.SchemaError) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), protocol.SchemaError) + }) + } + + for _, tc := range []struct { + path string + schema string + }{ + {scimResourceTypesPath + "/User", string(core.SchemaResourceType)}, + {scimSchemasPath + "/" + string(core.SchemaUser), string(core.SchemaSchema)}, + // A URN's colons are legal to percent-encode in a path segment, and + // some clients do, so both spellings must reach the same schema. + {scimSchemasPath + "/" + strings.ReplaceAll(string(core.SchemaUser), ":", "%3A"), string(core.SchemaSchema)}, + } { + t.Run(tc.path, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + + api.handler.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), tc.schema) }) } @@ -107,8 +130,8 @@ func TestSCIM(t *testing.T) { api.handler.ServeHTTP(w, r) require.Equal(t, http.StatusNotFound, w.Code) - require.Equal(t, scimProtocol.MediaType, w.Header().Get("Content-Type")) - require.Contains(t, w.Body.String(), scimProtocol.SchemaError) + require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), protocol.SchemaError) }) t.Run("Returns a SCIM 405 for an unsupported method", func(t *testing.T) {