Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions internal/models/sso.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package models

import (
"crypto/sha256"
"database/sql"
"database/sql/driver"
"encoding/hex"
"encoding/json"
"net/url"
"reflect"
Expand All @@ -13,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"
)

Expand All @@ -23,6 +26,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"`
}
Expand All @@ -39,6 +44,19 @@ func (p SSOProvider) Type() string {
return "saml"
}

func (p *SSOProvider) GenerateSCIMToken() string {
token := "scim_" + crypto.SecureAlphanumeric(32)
hash := toSHA256(token)
p.SCIMTokenHash = &hash

return token
}

func toSHA256(token string) string {
sum := sha256.Sum256([]byte(token))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: LOW

SCIMTokenHash is a bare, unsalted SHA-256 digest, while UpdateSCIMToken accepts arbitrary token strings without an entropy requirement. A database reader can test candidate low-entropy or provider-chosen tokens offline, recover a bearer token, and use it against the SCIM provider lookup.
Helpful? Add 👍 / 👎

💡 Fix Suggestion

Suggestion: There are two complementary mitigations needed:

  1. Enforce minimum token entropy: Change UpdateSCIMToken to return an error and reject tokens shorter than 32 bytes (e.g., if len(token) < 32 { return errors.New("SCIM token must be at least 32 characters") }). This prevents low-entropy tokens from being stored and makes offline dictionary attacks impractical. Update all callers accordingly.

  2. Replace unsalted SHA-256 with bcrypt: Since golang.org/x/crypto is already a dependency, replace toSHA256 with bcrypt.GenerateFromPassword([]byte(token), bcrypt.DefaultCost) so each stored hash gets a unique, random salt. This makes offline brute-force exponentially harder. Note that bcrypt is non-deterministic, so the equality-based lookup in FindSSOProviderBySCIMToken (WHERE scim_token_hash = ?) must be replaced with a fetch-then-compare pattern: load all providers with a non-null scim_token_hash (SCIM provider counts are small) and use bcrypt.CompareHashAndPassword to find a match. Alternatively, keep a fast SHA-256 lookup index but store a bcrypt hash as the authoritative credential field and verify using bcrypt after the indexed lookup.

return hex.EncodeToString(sum[:])
}

type SAMLAttribute struct {
Name string `json:"name,omitempty"`
Names []string `json:"names,omitempty"`
Expand Down Expand Up @@ -222,6 +240,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])
Expand Down
87 changes: 87 additions & 0 deletions internal/models/sso_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package models

import (
"crypto/sha256"
"encoding/hex"
"net/url"
"slices"
"testing"
Expand Down Expand Up @@ -469,3 +471,88 @@ func (ts *SSOTestSuite) TestFindSSOProviderByResourceID() {
require.Nil(ts.T(), got)
}
}

func (ts *SSOTestSuite) TestGenerateSCIMToken() {
provider := &SSOProvider{
SAMLProvider: SAMLProvider{
EntityID: "https://example.com/saml/metadata/",
MetadataXML: "<example />",
},
}
require.Nil(ts.T(), provider.SCIMTokenHash)

token := provider.GenerateSCIMToken()

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() {
sum := sha256.Sum256([]byte(token))

require.NotNil(ts.T(), 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() {
provider := &SSOProvider{
SAMLProvider: SAMLProvider{
EntityID: "https://example.com/saml/metadata/1",
MetadataXML: "<example />",
},
}

token := provider.GenerateSCIMToken()
require.NoError(ts.T(), ts.db.Eager().Create(provider))

withoutToken := &SSOProvider{
SAMLProvider: SAMLProvider{
EntityID: "https://example.com/saml/metadata/2",
MetadataXML: "<example />",
},
}
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 := provider.GenerateSCIMToken()
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))
})
}
Original file line number Diff line number Diff line change
@@ -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;
Loading