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;