Skip to content

Commit 76e14c9

Browse files
committed
feat(scim): store a per-provider SCIM token hash
Adds `sso_providers.scim_token_hash`, a SHA-256 hex digest of the provider's SCIM token, with a partial unique index over the non-null values. The plaintext is never stored, and resolution is a single indexed equality on the digest rather than a comparison.
1 parent a910ba6 commit 76e14c9

3 files changed

Lines changed: 110 additions & 0 deletions

File tree

‎internal/models/sso.go‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package models
22

33
import (
4+
"crypto/sha256"
45
"database/sql"
56
"database/sql/driver"
7+
"encoding/hex"
68
"encoding/json"
79
"net/url"
810
"reflect"
@@ -23,6 +25,8 @@ type SSOProvider struct {
2325
SAMLProvider SAMLProvider `has_one:"saml_providers" fk_id:"sso_provider_id" json:"saml,omitempty"`
2426
SSODomains []SSODomain `has_many:"sso_domains" fk_id:"sso_provider_id" json:"domains"`
2527

28+
SCIMTokenHash *string `db:"scim_token_hash" json:"-"`
29+
2630
CreatedAt time.Time `db:"created_at" json:"created_at"`
2731
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
2832
}
@@ -39,6 +43,16 @@ func (p SSOProvider) Type() string {
3943
return "saml"
4044
}
4145

46+
func (p *SSOProvider) UpdateSCIMToken(token string) {
47+
hash := toSHA256(token)
48+
p.SCIMTokenHash = &hash
49+
}
50+
51+
func toSHA256(token string) string {
52+
sum := sha256.Sum256([]byte(token))
53+
return hex.EncodeToString(sum[:])
54+
}
55+
4256
type SAMLAttribute struct {
4357
Name string `json:"name,omitempty"`
4458
Names []string `json:"names,omitempty"`
@@ -222,6 +236,20 @@ func FindSSOProviderByResourceID(tx *storage.Connection, id string) (*SSOProvide
222236
return &ssoProvider, nil
223237
}
224238

239+
func FindSSOProviderBySCIMToken(tx *storage.Connection, token string) (*SSOProvider, error) {
240+
var ssoProvider SSOProvider
241+
242+
if err := tx.Q().Where("scim_token_hash = ?", toSHA256(token)).First(&ssoProvider); err != nil {
243+
if errors.Cause(err) == sql.ErrNoRows {
244+
return nil, SSOProviderNotFoundError{}
245+
}
246+
247+
return nil, errors.Wrap(err, "error finding SSO provider by SCIM token")
248+
}
249+
250+
return &ssoProvider, nil
251+
}
252+
225253
func FindSSOProviderForEmailAddress(tx *storage.Connection, emailAddress string) (*SSOProvider, error) {
226254
parts := strings.Split(emailAddress, "@")
227255
emailDomain := strings.ToLower(parts[1])

‎internal/models/sso_test.go‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,3 +469,76 @@ func (ts *SSOTestSuite) TestFindSSOProviderByResourceID() {
469469
require.Nil(ts.T(), got)
470470
}
471471
}
472+
473+
func buildSSOProvider() *SSOProvider {
474+
id := uuid.Must(uuid.NewV4()).String()
475+
476+
return &SSOProvider{
477+
SAMLProvider: SAMLProvider{
478+
EntityID: "https://example.com/saml/metadata/" + id,
479+
MetadataXML: "<example />",
480+
},
481+
}
482+
}
483+
484+
func (ts *SSOTestSuite) TestUpdateSCIMToken() {
485+
hashes := map[string]string{
486+
"scim_test_token": "dcbcd9ffd696ae1f2ee0f035fa17680d78175020a5fa1aadc758dbd681e0fe1d",
487+
"scim_rotated_token": "289adb37f8946571bb4aea1e663281126c7f2d84d929ff09429fcaa1eb3f27bf",
488+
}
489+
490+
provider := buildSSOProvider()
491+
require.Nil(ts.T(), provider.SCIMTokenHash)
492+
493+
for token, hash := range hashes {
494+
provider.UpdateSCIMToken(token)
495+
require.NotNil(ts.T(), provider.SCIMTokenHash)
496+
require.Equal(ts.T(), hash, *provider.SCIMTokenHash)
497+
}
498+
}
499+
500+
func (ts *SSOTestSuite) TestFindSSOProviderBySCIMToken() {
501+
provider := buildSSOProvider()
502+
503+
token := "scim_test_token"
504+
provider.UpdateSCIMToken(token)
505+
require.NoError(ts.T(), ts.db.Eager().Create(provider))
506+
507+
withoutToken := buildSSOProvider()
508+
require.NoError(ts.T(), ts.db.Eager().Create(withoutToken))
509+
510+
ts.Run("resolves the provider that owns the token", func() {
511+
found, err := FindSSOProviderBySCIMToken(ts.db, token)
512+
513+
require.NoError(ts.T(), err)
514+
require.Equal(ts.T(), provider.ID, found.ID)
515+
})
516+
517+
ts.Run("an unknown token resolves nothing", func() {
518+
found, err := FindSSOProviderBySCIMToken(ts.db, "scim_unknown_token")
519+
520+
require.Nil(ts.T(), found)
521+
require.True(ts.T(), IsNotFoundError(err))
522+
})
523+
524+
ts.Run("an empty token does not match a provider without one", func() {
525+
found, err := FindSSOProviderBySCIMToken(ts.db, "")
526+
527+
require.Nil(ts.T(), found)
528+
require.True(ts.T(), IsNotFoundError(err))
529+
})
530+
531+
ts.Run("rotation stops the previous token from resolving", func() {
532+
newToken := "scim_rotated_token"
533+
provider.UpdateSCIMToken(newToken)
534+
require.NoError(ts.T(), ts.db.Update(provider))
535+
536+
found, err := FindSSOProviderBySCIMToken(ts.db, newToken)
537+
require.NoError(ts.T(), err)
538+
require.Equal(ts.T(), provider.ID, found.ID)
539+
540+
found, err = FindSSOProviderBySCIMToken(ts.db, token)
541+
require.Nil(ts.T(), found)
542+
require.True(ts.T(), IsNotFoundError(err))
543+
})
544+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- Holds the SHA-256 hex digest of the provider's SCIM token.
2+
/* auth_migration: 20260731000000 */
3+
alter table only {{ index .Options "Namespace" }}.sso_providers
4+
add column if not exists scim_token_hash text null;
5+
6+
/* auth_migration: 20260731000000 */
7+
create unique index if not exists sso_providers_scim_token_hash_idx
8+
on {{ index .Options "Namespace" }}.sso_providers (scim_token_hash)
9+
where scim_token_hash is not null;

0 commit comments

Comments
 (0)