diff --git a/internal/api/api.go b/internal/api/api.go index 75cc4cc04..eef9fedbd 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) r.WithBypass(api.scim.Authenticate).Get("/Users/{id}", api.scim.UserByID) }) diff --git a/internal/api/scim/core/attribute.go b/internal/api/scim/core/attribute.go new file mode 100644 index 000000000..37558d7f6 --- /dev/null +++ b/internal/api/scim/core/attribute.go @@ -0,0 +1,96 @@ +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" +) + +// 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"` + CaseExact bool `json:"caseExact"` + Mutability Mutability `json:"mutability"` + Returned Returned `json:"returned"` + Uniqueness Uniqueness `json:"uniqueness"` + SubAttributes []*Attribute `json:"subAttributes,omitempty"` +} + +// NewAttribute returns an attribute carrying the characteristic defaults +// RFC 7643, Section 7 declares: readWrite mutability, default returnability +// and no uniqueness. The As* and With modifiers state the deviations. +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 +} + +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/meta.go b/internal/api/scim/core/meta.go index c03522189..e436e5102 100644 --- a/internal/api/scim/core/meta.go +++ b/internal/api/scim/core/meta.go @@ -10,7 +10,10 @@ type Meta struct { } func NewMeta(baseURL string, resourceType ResourceType) Meta { - return resourceType.Meta(baseURL) + return Meta{ + ResourceType: resourceType.Name, + Location: resourceType.Location(baseURL), + } } func (m Meta) For(r Resource) Meta { diff --git a/internal/api/scim/core/resource_type.go b/internal/api/scim/core/resource_type.go index 234a9eee8..d339152d5 100644 --- a/internal/api/scim/core/resource_type.go +++ b/internal/api/scim/core/resource_type.go @@ -1,6 +1,23 @@ package core +import "time" + var ( + ResourceTypeGroup = ResourceType{ + Name: "Group", + Endpoint: "/Groups", + } + + ResourceTypeResourceType = ResourceType{ + Name: "ResourceType", + Endpoint: "/ResourceTypes", + } + + ResourceTypeSchema = ResourceType{ + Name: "Schema", + Endpoint: "/Schemas", + } + ResourceTypeServiceProviderConfig = ResourceType{ Name: "ServiceProviderConfig", Endpoint: "/ServiceProviderConfig", @@ -14,18 +31,45 @@ var ( type ResourceTypeName string +// ResourceType is the resource type metadata defined in RFC 7643, Section 6. +// The package-level values above carry only the name and endpoint, which is +// all that locating a resource needs; NewResourceType fills in the rest for +// the ones served from /ResourceTypes. type ResourceType struct { - Name ResourceTypeName - Endpoint string + 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"` + Meta Meta `json:"meta,omitzero"` } -func (r ResourceType) Meta(baseURL string) Meta { - return Meta{ - ResourceType: r.Name, - Location: r.Location(baseURL), +// NewResourceType builds the resource type describing schema, served at +// endpoint. Taking the schema rather than its URI is what makes RFC 7643, +// Section 6 hold by construction: the resource type's schema attribute "MUST +// be equal to the id attribute of the associated Schema resource". +func NewResourceType(baseURL string, schema *Schema, endpoint string) *ResourceType { + resourceType := &ResourceType{ + Schemas: []SchemaURI{SchemaResourceType}, + ID: schema.Name, + Name: schema.Name, + Description: schema.Description, + Endpoint: endpoint, + Schema: schema.ID, } + resourceType.Meta = NewMeta(baseURL, ResourceTypeResourceType).For(resourceType) + + return resourceType } func (r ResourceType) Location(baseURL string) string { return baseURL + r.Endpoint } + +func (r *ResourceType) ResourceID() string { return string(r.ID) } + +func (r *ResourceType) ResourceType() ResourceType { return ResourceTypeResourceType } + +// Timestamps reports none, because a resource type is static. +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 index 1abc4b9ae..5c5d3c692 100644 --- a/internal/api/scim/core/resource_type_test.go +++ b/internal/api/scim/core/resource_type_test.go @@ -1,24 +1,47 @@ package core import ( + "encoding/json" "testing" "github.com/stretchr/testify/require" ) func TestResourceType(t *testing.T) { - r := ResourceType{Name: "Resource", Endpoint: "/Resources"} + baseURL := "http://localhost:9999/scim/v2" - 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 := NewMeta(baseURL, ResourceType{Name: "Resource", Endpoint: "/Resources"}) - 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) + }) + + t.Run("NewResourceType", func(t *testing.T) { + schema := NewSchema(baseURL, SchemaUser, ResourceTypeUser.Name).Describe("User Account") + + resourceType := NewResourceType(baseURL, schema, ResourceTypeUser.Endpoint) + + t.Run("takes its identity and description from the schema", func(t *testing.T) { + require.Equal(t, ResourceTypeUser.Name, resourceType.ID) + require.Equal(t, ResourceTypeUser.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, ResourceTypeResourceType.Name, resourceType.Meta.ResourceType) + require.Equal(t, baseURL+"/ResourceTypes/User", resourceType.Meta.Location) + }) + + t.Run("reports no timestamps, because it is static", func(t *testing.T) { + body, err := json.Marshal(resourceType) - require.Equal(t, ResourceTypeName("Resource"), meta.ResourceType) - require.Equal(t, baseURL+"/Resources", meta.Location) - require.Zero(t, meta.Created) - require.Zero(t, meta.LastModified) + require.NoError(t, err) + require.NotContains(t, string(body), `"created"`) + require.NotContains(t, string(body), `"lastModified"`) }) }) } diff --git a/internal/api/scim/core/schema.go b/internal/api/scim/core/schema.go new file mode 100644 index 000000000..86071437d --- /dev/null +++ b/internal/api/scim/core/schema.go @@ -0,0 +1,43 @@ +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"` +} + +// NewSchema builds the schema definition resource for id. Attributes and +// description are optional and set with With and Describe. +func NewSchema(baseURL string, id SchemaURI, name ResourceTypeName) *Schema { + schema := &Schema{ + Schemas: []SchemaURI{SchemaSchema}, + ID: id, + Name: name, + } + schema.Meta = NewMeta(baseURL, ResourceTypeSchema).For(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) ResourceType() ResourceType { return ResourceTypeSchema } + +// Timestamps reports none, because a schema is static. +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 026d49c6b..7f8fe965c 100644 --- a/internal/api/scim/core/schemas.go +++ b/internal/api/scim/core/schemas.go @@ -5,6 +5,9 @@ const ( schemaRoot = "urn:ietf:params:scim:schemas" schemaCore = schemaRoot + ":core:2.0" + 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 c684c6c9a..5a4f54a72 100644 --- a/internal/api/scim/core/service_provider_config.go +++ b/internal/api/scim/core/service_provider_config.go @@ -20,6 +20,6 @@ func NewServiceProviderConfig(baseURL string, schemes ...*AuthenticationScheme) return &ServiceProviderConfig{ Schemas: []SchemaURI{SchemaServiceProviderConfig}, AuthenticationSchemes: schemes, - Meta: ResourceTypeServiceProviderConfig.Meta(baseURL), + Meta: NewMeta(baseURL, ResourceTypeServiceProviderConfig), } } diff --git a/internal/api/scim/core/user.go b/internal/api/scim/core/user.go index ca58472cd..07ea95005 100644 --- a/internal/api/scim/core/user.go +++ b/internal/api/scim/core/user.go @@ -15,10 +15,12 @@ type Name struct { // 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"` + 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"` + Meta Meta `json:"meta"` } diff --git a/internal/api/scim/core/user_test.go b/internal/api/scim/core/user_test.go index 080cde140..a57cd827d 100644 --- a/internal/api/scim/core/user_test.go +++ b/internal/api/scim/core/user_test.go @@ -13,10 +13,13 @@ func TestUser(t *testing.T) { 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}}, + 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: true, Meta: Meta{ ResourceType: ResourceTypeUser.Name, Created: created, @@ -32,8 +35,11 @@ func TestUser(t *testing.T) { 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", @@ -43,22 +49,24 @@ func TestUser(t *testing.T) { }`, string(body)) }) - t.Run("omits emails when there are none", func(t *testing.T) { - user.Emails = nil + t.Run("omits the optional attributes when they are unset", func(t *testing.T) { + user.Emails, user.Name, user.ExternalID = nil, Name{}, "" body, err := json.Marshal(user) require.NoError(t, err) - require.NotContains(t, string(body), "emails") + require.NotContains(t, string(body), `"emails"`) + require.NotContains(t, string(body), `"name"`) + require.NotContains(t, string(body), `"externalId"`) }) - t.Run("omits the name when there is none", func(t *testing.T) { - user.Name = Name{} + t.Run("always reports active, so a deactivated user is not read as unknown", func(t *testing.T) { + user.Active = false body, err := json.Marshal(user) require.NoError(t, err) - require.NotContains(t, string(body), `"name"`) + require.Contains(t, string(body), `"active":false`) }) t.Run("serializes only the name components that are set", func(t *testing.T) { diff --git a/internal/api/scim/discovery.go b/internal/api/scim/discovery.go new file mode 100644 index 000000000..7486a3b2e --- /dev/null +++ b/internal/api/scim/discovery.go @@ -0,0 +1,38 @@ +package scim + +import "github.com/supabase/auth/internal/api/scim/core" + +func newUserSchema(baseURL string) *core.Schema { + return core. + NewSchema(baseURL, core.SchemaUser, core.ResourceTypeUser.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), + + core.NewAttribute("name", core.TypeComplex, + "The components of the user's real name."). + With( + core.NewAttribute("formatted", core.TypeString, "The full name, including all middle names, titles, and suffixes as appropriate, formatted for display."), + core.NewAttribute("familyName", core.TypeString, "The family name of the User, or last name in most Western languages."), + core.NewAttribute("givenName", core.TypeString, "The given name of the User, or first name in most Western languages."), + ), + + core.NewAttribute("emails", core.TypeComplex, + "Email addresses for the User. Only the primary address is supported."). + AsMultiValued(). + With( + core.NewAttribute("value", core.TypeString, "Email address for the User."), + core.NewAttribute("primary", core.TypeBoolean, "A Boolean value indicating the preferred email address."), + ), + + core.NewAttribute("active", core.TypeBoolean, + "A Boolean value indicating the User's administrative status."), + + core.NewAttribute("externalId", core.TypeString, + "An identifier for the User as defined by the provisioning client."). + AsCaseExact(), + ) +} diff --git a/internal/api/scim/mapper.go b/internal/api/scim/mapper.go index f1433e6ba..1f82c9184 100644 --- a/internal/api/scim/mapper.go +++ b/internal/api/scim/mapper.go @@ -29,6 +29,7 @@ func (m UserMapper) MapFrom(in *models.ProvisionedUser) *core.User { MiddleName: in.Claim("middle_name"), }, Emails: []core.Email{{Value: in.PrimaryEmail(), Primary: true}}, - Meta: in.ResourceType().Meta(m.baseURL).For(in), + Active: !in.IsBanned(), + Meta: core.NewMeta(m.baseURL, in.ResourceType()).For(in), } } diff --git a/internal/api/scim/protocol/error.go b/internal/api/scim/protocol/error.go index fb183692f..6de418135 100644 --- a/internal/api/scim/protocol/error.go +++ b/internal/api/scim/protocol/error.go @@ -4,17 +4,31 @@ import ( "strconv" ) -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"` + 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}, ScimType: scimType, diff --git a/internal/api/scim/protocol/error_test.go b/internal/api/scim/protocol/error_test.go index aede262a8..0b8ce0d8d 100644 --- a/internal/api/scim/protocol/error_test.go +++ b/internal/api/scim/protocol/error_test.go @@ -9,18 +9,18 @@ import ( "github.com/stretchr/testify/require" ) +const notFoundError = `{ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "404", + "detail": "Endpoint or resource does not exist" +}` + func TestNewError(t *testing.T) { t.Run("serializes to JSON correctly", func(t *testing.T) { body, err := json.Marshal(NewError(http.StatusNotFound, "", "Endpoint or resource does not exist")) require.NoError(t, err) - assert.JSONEq(t, `{ - "schemas": [ - "urn:ietf:params:scim:api:messages:2.0:Error" - ], - "status": "404", - "detail": "Endpoint or resource does not exist" - }`, string(body)) + assert.JSONEq(t, notFoundError, string(body)) }) t.Run("includes the scimType when one is given", func(t *testing.T) { diff --git a/internal/api/scim/protocol/list_response.go b/internal/api/scim/protocol/list_response.go index 972229f71..ef095060e 100644 --- a/internal/api/scim/protocol/list_response.go +++ b/internal/api/scim/protocol/list_response.go @@ -1,7 +1,5 @@ package protocol -const SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" - type ListResponse[T any] struct { Schemas []string `json:"schemas"` TotalResults int `json:"totalResults"` diff --git a/internal/api/scim/protocol/messages.go b/internal/api/scim/protocol/messages.go new file mode 100644 index 000000000..22a312de3 --- /dev/null +++ b/internal/api/scim/protocol/messages.go @@ -0,0 +1,14 @@ +package protocol + +// The message URIs of RFC 7644, composed from the registered URN namespace so +// the prefix is declared once. +const ( + messagesRoot = "urn:ietf:params:scim:api:messages:2.0" + + SchemaBulkRequest = messagesRoot + ":BulkRequest" + SchemaBulkResponse = messagesRoot + ":BulkResponse" + SchemaError = messagesRoot + ":Error" + SchemaListResponse = messagesRoot + ":ListResponse" + SchemaPatchOp = messagesRoot + ":PatchOp" + SchemaSearchRequest = messagesRoot + ":SearchRequest" +) 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 38abbe19e..89aeaa010 100644 --- a/internal/api/scim/server.go +++ b/internal/api/scim/server.go @@ -2,8 +2,10 @@ package scim import ( "net/http" + "net/url" "strings" + "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" @@ -21,10 +23,13 @@ type Server struct { extract TokenExtractor users Mapper[*models.ProvisionedUser, *core.User] serviceProviderConfig *core.ServiceProviderConfig + resourceTypes []*core.ResourceType + schemas []*core.Schema } func NewServer(config *conf.GlobalConfiguration, db *storage.Connection, extract TokenExtractor) *Server { baseURL := strings.TrimRight(config.API.ExternalURL, "/") + BasePath + userSchema := newUserSchema(baseURL) return &Server{ db: db, @@ -34,6 +39,8 @@ func NewServer(config *conf.GlobalConfiguration, db *storage.Connection, extract baseURL, core.NewOAuthBearerToken().AsPrimary(), ), + resourceTypes: []*core.ResourceType{core.NewResourceType(baseURL, userSchema, core.ResourceTypeUser.Endpoint)}, + schemas: []*core.Schema{userSchema}, } } @@ -42,11 +49,45 @@ 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 { + id := urlParam(r, "id") + + for _, resourceType := range srv.resourceTypes { + if resourceType.ID == core.ResourceTypeName(id) { + return protocol.Send(w, http.StatusOK, resourceType) + } + } + return srv.NotFound(w, r) } 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 { + id := urlParam(r, "id") + + for _, schema := range srv.schemas { + if schema.ID == core.SchemaURI(id) { + return protocol.Send(w, http.StatusOK, schema) + } + } + return srv.NotFound(w, r) +} + +// urlParam is chi.URLParam with the percent-encoding undone. chi matches +// against the raw path when one is present, so a client that encodes the +// colons of a schema URN gets back the encoded segment. +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 { diff --git a/internal/api/scim/server_test.go b/internal/api/scim/server_test.go index 8c1580441..b86361973 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" ) @@ -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.ResourceTypeUser.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,31 @@ 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) { + w := httptest.NewRecorder() + + require.NoError(t, tc.byID(w, requestWithURLParam(tc.path+"/"+tc.id, "id", tc.id))) + + 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) { + w := httptest.NewRecorder() + + require.NoError(t, tc.byID(w, requestWithURLParam(tc.path+"/Unknown", "id", "Unknown"))) + + 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) { @@ -89,3 +120,12 @@ func TestServer(t *testing.T) { 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..591a87e87 --- /dev/null +++ b/internal/api/scim/testdata/schema_user.json @@ -0,0 +1,128 @@ +{ + "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" + }, + { + "name": "name", + "type": "complex", + "multiValued": false, + "description": "The components of the user's real name.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "formatted", + "type": "string", + "multiValued": false, + "description": "The full name, including all middle names, titles, and suffixes as appropriate, formatted for display.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "familyName", + "type": "string", + "multiValued": false, + "description": "The family name of the User, or last name in most Western languages.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "givenName", + "type": "string", + "multiValued": false, + "description": "The given name of the User, or first name in most Western languages.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "emails", + "type": "complex", + "multiValued": true, + "description": "Email addresses for the User. Only the primary address is supported.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none", + "subAttributes": [ + { + "name": "value", + "type": "string", + "multiValued": false, + "description": "Email address for the User.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "primary", + "type": "boolean", + "multiValued": false, + "description": "A Boolean value indicating the preferred email address.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + } + ] + }, + { + "name": "active", + "type": "boolean", + "multiValued": false, + "description": "A Boolean value indicating the User's administrative status.", + "required": false, + "caseExact": false, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + }, + { + "name": "externalId", + "type": "string", + "multiValued": false, + "description": "An identifier for the User as defined by the provisioning client.", + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "default", + "uniqueness": "none" + } + ], + "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..545ec6aad 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "github.com/stretchr/testify/require" @@ -19,7 +20,7 @@ const ( scimSchemasPath = "/scim/v2/Schemas" ) -var scimPaths = []string{ +var discoveryPaths = []string{ scimServiceProviderConfigPath, scimResourceTypesPath, scimSchemasPath, @@ -32,7 +33,7 @@ func TestSCIM(t *testing.T) { require.False(t, api.config.Experimental.ScimEnabled) - for _, path := range scimPaths { + for _, path := range discoveryPaths { r := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() @@ -72,7 +73,7 @@ func TestSCIM(t *testing.T) { 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.Contains(t, w.Body.String(), string(scimCore.SchemaServiceProviderConfig)) }) for _, path := range []string{scimResourceTypesPath, scimSchemasPath} { @@ -100,6 +101,28 @@ func TestSCIM(t *testing.T) { }) } + for _, tc := range []struct { + path string + schema string + }{ + {scimResourceTypesPath + "/User", string(scimCore.SchemaResourceType)}, + {scimSchemasPath + "/" + string(scimCore.SchemaUser), string(scimCore.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(scimCore.SchemaUser), ":", "%3A"), string(scimCore.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, scimProtocol.MediaType, w.Header().Get("Content-Type")) + require.Contains(t, w.Body.String(), tc.schema) + }) + } + t.Run("Returns a SCIM 404 for an unknown endpoint", func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/scim/v2/Unknown", nil) w := httptest.NewRecorder() @@ -113,7 +136,7 @@ func TestSCIM(t *testing.T) { t.Run("Returns a SCIM 405 for an unsupported method", func(t *testing.T) { for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { - for _, path := range scimPaths { + for _, path := range discoveryPaths { t.Run(method+" "+path, func(t *testing.T) { r := httptest.NewRequest(method, path, nil) w := httptest.NewRecorder() diff --git a/internal/api/scim_users_test.go b/internal/api/scim_users_test.go index 39e21b5ac..f810d7695 100644 --- a/internal/api/scim_users_test.go +++ b/internal/api/scim_users_test.go @@ -80,6 +80,7 @@ func (ts *SCIMUsersTestSuite) TestGetUser() { "id": %q, "userName": %q, "emails": [{"value": %q, "primary": true}], + "active": true, "meta": { "resourceType": "User", "created": %q, @@ -138,6 +139,18 @@ func (ts *SCIMUsersTestSuite) TestGetUser() { require.NotContains(ts.T(), w.Body.String(), "engineering") }) + ts.Run("reports a banned user as inactive", func() { + user := seedSCIMUser(ts.T(), ts.API.db, ts.TenantA.provider, "banned@"+ts.TenantA.domain) + bannedUntil := time.Now().Add(24 * time.Hour) + user.BannedUntil = &bannedUntil + require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until")) + + w := ts.get(user.ID.String(), ts.TenantA.token) + + require.Equal(ts.T(), http.StatusOK, w.Code) + require.Contains(ts.T(), w.Body.String(), `"active":false`) + }) + 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(), @@ -173,6 +186,7 @@ func (ts *SCIMUsersTestSuite) TestGetUser() { "middleName": "Jane" }, "emails": [{"value": "bjensen@example.com", "primary": true}], + "active": true, "meta": { "resourceType": "User", "created": %q, diff --git a/internal/api/shared/json_test.go b/internal/api/shared/json_test.go new file mode 100644 index 000000000..612e2b64e --- /dev/null +++ b/internal/api/shared/json_test.go @@ -0,0 +1,57 @@ +package shared + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSON(t *testing.T) { + t.Run("Defaults to JSON", func(t *testing.T) { + w := httptest.NewRecorder() + + json := JSON(w) + require.NotNil(t, json) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + }) + + t.Run("Adds a Header", func(t *testing.T) { + w := httptest.NewRecorder() + + json := JSON(w).Header("X-Request-ID", "1") + require.NotNil(t, json) + assert.Equal(t, "1", w.Header().Get("X-Request-ID")) + }) + + t.Run("Uses a custom Content-Type", func(t *testing.T) { + w := httptest.NewRecorder() + + json := JSON(w).ContentType("application/scim+json") + require.NotNil(t, json) + assert.Equal(t, "application/scim+json", w.Header().Get("Content-Type")) + }) + + t.Run("Writes data to the response body", func(t *testing.T) { + w := httptest.NewRecorder() + b := []byte("hello, world") + + err := JSON(w).Write(b) + require.NoError(t, err) + assert.Equal(t, "hello, world", w.Body.String()) + }) + + t.Run("Serializes a type to JSON", func(t *testing.T) { + w := httptest.NewRecorder() + type person struct { + Name string `json:"name"` + } + + err := JSON(w).Send(&person{Name: "gilfoyle"}) + require.NoError(t, err) + assert.Equal(t, `{"name":"gilfoyle"}`, w.Body.String()) + }) +}