From cd7a47435d78b9a9945cf122bc5487c2664a1a0b Mon Sep 17 00:00:00 2001 From: mo khan Date: Thu, 30 Jul 2026 16:29:49 -0600 Subject: [PATCH 1/3] feat(scim): store a per-provider SCIM token hash --- internal/models/sso.go | 28 +++++++ internal/models/sso_test.go | 77 +++++++++++++++++++ ...dd_scim_token_hash_to_sso_providers.up.sql | 9 +++ 3 files changed, 114 insertions(+) create mode 100644 migrations/20260731000000_add_scim_token_hash_to_sso_providers.up.sql diff --git a/internal/models/sso.go b/internal/models/sso.go index 3a5be7d973..ca90de966a 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,16 @@ func (p SSOProvider) Type() string { return "saml" } +func (p *SSOProvider) UpdateSCIMToken(token string) { + hash := toSHA256(token) + p.SCIMTokenHash = &hash +} + +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 +236,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 523ad614c7..cd06cc1960 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/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 0000000000..fd8afeadf7 --- /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; From c94ceb6e9ecbbae4fbbb8b8842f7b4a122a9ce2a Mon Sep 17 00:00:00 2001 From: mo khan Date: Wed, 12 Aug 2026 16:54:02 -0600 Subject: [PATCH 2/3] feat(scim): generate SCIM token --- internal/models/sso.go | 6 +++++- internal/models/sso_test.go | 42 ++++++++++++++++++++++++------------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/internal/models/sso.go b/internal/models/sso.go index ca90de966a..b6bb3f984d 100644 --- a/internal/models/sso.go +++ b/internal/models/sso.go @@ -1,6 +1,7 @@ package models import ( + "crypto/rand" "crypto/sha256" "database/sql" "database/sql/driver" @@ -43,9 +44,12 @@ func (p SSOProvider) Type() string { return "saml" } -func (p *SSOProvider) UpdateSCIMToken(token string) { +func (p *SSOProvider) GenerateSCIMToken() string { + token := rand.Text() hash := toSHA256(token) p.SCIMTokenHash = &hash + + return token } func toSHA256(token string) string { diff --git a/internal/models/sso_test.go b/internal/models/sso_test.go index cd06cc1960..d7e36629b5 100644 --- a/internal/models/sso_test.go +++ b/internal/models/sso_test.go @@ -1,6 +1,9 @@ package models import ( + "crypto/sha256" + "encoding/base32" + "encoding/hex" "net/url" "slices" "testing" @@ -470,12 +473,7 @@ func (ts *SSOTestSuite) TestFindSSOProviderByResourceID() { } } -func (ts *SSOTestSuite) TestUpdateSCIMToken() { - hashes := map[string]string{ - "scim_test_token": "dcbcd9ffd696ae1f2ee0f035fa17680d78175020a5fa1aadc758dbd681e0fe1d", - "scim_rotated_token": "289adb37f8946571bb4aea1e663281126c7f2d84d929ff09429fcaa1eb3f27bf", - } - +func (ts *SSOTestSuite) TestGenerateSCIMToken() { provider := &SSOProvider{ SAMLProvider: SAMLProvider{ EntityID: "https://example.com/saml/metadata/", @@ -484,15 +482,32 @@ func (ts *SSOTestSuite) TestUpdateSCIMToken() { } require.Nil(ts.T(), provider.SCIMTokenHash) - for token, hash := range hashes { - provider.UpdateSCIMToken(token) + token := provider.GenerateSCIMToken() + + ts.Run("returns at least 128 bits of base32 randomness", func() { + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(token) + + require.NoError(ts.T(), err) + require.GreaterOrEqual(ts.T(), len(decoded), 16) + }) + + ts.Run("stores the SHA-256 digest of the token", func() { + sum := sha256.Sum256([]byte(token)) + require.NotNil(ts.T(), provider.SCIMTokenHash) - require.Equal(ts.T(), hash, *provider.SCIMTokenHash) - } + require.Equal(ts.T(), hex.EncodeToString(sum[:]), *provider.SCIMTokenHash) + }) + + ts.Run("never stores the token itself", func() { + require.NotContains(ts.T(), *provider.SCIMTokenHash, token) + }) + + ts.Run("generates a distinct token on every call", func() { + require.NotEqual(ts.T(), token, provider.GenerateSCIMToken()) + }) } func (ts *SSOTestSuite) TestFindSSOProviderBySCIMToken() { - token := "scim_test_token" provider := &SSOProvider{ SAMLProvider: SAMLProvider{ EntityID: "https://example.com/saml/metadata/1", @@ -500,7 +515,7 @@ func (ts *SSOTestSuite) TestFindSSOProviderBySCIMToken() { }, } - provider.UpdateSCIMToken(token) + token := provider.GenerateSCIMToken() require.NoError(ts.T(), ts.db.Eager().Create(provider)) withoutToken := &SSOProvider{ @@ -533,8 +548,7 @@ func (ts *SSOTestSuite) TestFindSSOProviderBySCIMToken() { }) ts.Run("rotation stops the previous token from resolving", func() { - newToken := "scim_rotated_token" - provider.UpdateSCIMToken(newToken) + newToken := provider.GenerateSCIMToken() require.NoError(ts.T(), ts.db.Update(provider)) found, err := FindSSOProviderBySCIMToken(ts.db, newToken) From 6277756f1be7e4450b10700cec0b493b491a2032 Mon Sep 17 00:00:00 2001 From: mo khan Date: Thu, 13 Aug 2026 10:38:55 -0600 Subject: [PATCH 3/3] feat(scim): generate token with a prefix --- internal/models/sso.go | 4 ++-- internal/models/sso_test.go | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/internal/models/sso.go b/internal/models/sso.go index b6bb3f984d..fef0658a26 100644 --- a/internal/models/sso.go +++ b/internal/models/sso.go @@ -1,7 +1,6 @@ package models import ( - "crypto/rand" "crypto/sha256" "database/sql" "database/sql/driver" @@ -16,6 +15,7 @@ import ( "github.com/crewjam/saml/samlsp" "github.com/gofrs/uuid" "github.com/pkg/errors" + "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/storage" ) @@ -45,7 +45,7 @@ func (p SSOProvider) Type() string { } func (p *SSOProvider) GenerateSCIMToken() string { - token := rand.Text() + token := "scim_" + crypto.SecureAlphanumeric(32) hash := toSHA256(token) p.SCIMTokenHash = &hash diff --git a/internal/models/sso_test.go b/internal/models/sso_test.go index d7e36629b5..a5ad7d0bb9 100644 --- a/internal/models/sso_test.go +++ b/internal/models/sso_test.go @@ -2,7 +2,6 @@ package models import ( "crypto/sha256" - "encoding/base32" "encoding/hex" "net/url" "slices" @@ -484,11 +483,8 @@ func (ts *SSOTestSuite) TestGenerateSCIMToken() { token := provider.GenerateSCIMToken() - ts.Run("returns at least 128 bits of base32 randomness", func() { - decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(token) - - require.NoError(ts.T(), err) - require.GreaterOrEqual(ts.T(), len(decoded), 16) + ts.Run("returns a scim_ prefixed token with 160 bits of randomness", func() { + require.Regexp(ts.T(), `^scim_[a-z2-7]{32}$`, token) }) ts.Run("stores the SHA-256 digest of the token", func() {