Skip to content

Commit 210713b

Browse files
committed
feat(scim): store a per-provider SCIM token hash
1 parent 1e8f639 commit 210713b

3 files changed

Lines changed: 114 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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,3 +469,80 @@ func (ts *SSOTestSuite) TestFindSSOProviderByResourceID() {
469469
require.Nil(ts.T(), got)
470470
}
471471
}
472+
473+
func (ts *SSOTestSuite) TestUpdateSCIMToken() {
474+
hashes := map[string]string{
475+
"scim_test_token": "dcbcd9ffd696ae1f2ee0f035fa17680d78175020a5fa1aadc758dbd681e0fe1d",
476+
"scim_rotated_token": "289adb37f8946571bb4aea1e663281126c7f2d84d929ff09429fcaa1eb3f27bf",
477+
}
478+
479+
provider := &SSOProvider{
480+
SAMLProvider: SAMLProvider{
481+
EntityID: "https://example.com/saml/metadata/",
482+
MetadataXML: "<example />",
483+
},
484+
}
485+
require.Nil(ts.T(), provider.SCIMTokenHash)
486+
487+
for token, hash := range hashes {
488+
provider.UpdateSCIMToken(token)
489+
require.NotNil(ts.T(), provider.SCIMTokenHash)
490+
require.Equal(ts.T(), hash, *provider.SCIMTokenHash)
491+
}
492+
}
493+
494+
func (ts *SSOTestSuite) TestFindSSOProviderBySCIMToken() {
495+
token := "scim_test_token"
496+
provider := &SSOProvider{
497+
SAMLProvider: SAMLProvider{
498+
EntityID: "https://example.com/saml/metadata/1",
499+
MetadataXML: "<example />",
500+
},
501+
}
502+
503+
provider.UpdateSCIMToken(token)
504+
require.NoError(ts.T(), ts.db.Eager().Create(provider))
505+
506+
withoutToken := &SSOProvider{
507+
SAMLProvider: SAMLProvider{
508+
EntityID: "https://example.com/saml/metadata/2",
509+
MetadataXML: "<example />",
510+
},
511+
}
512+
require.NoError(ts.T(), ts.db.Eager().Create(withoutToken))
513+
514+
ts.Run("resolves the provider that owns the token", func() {
515+
found, err := FindSSOProviderBySCIMToken(ts.db, token)
516+
517+
require.NoError(ts.T(), err)
518+
require.Equal(ts.T(), provider.ID, found.ID)
519+
})
520+
521+
ts.Run("an unknown token resolves nothing", func() {
522+
found, err := FindSSOProviderBySCIMToken(ts.db, "scim_unknown_token")
523+
524+
require.Nil(ts.T(), found)
525+
require.True(ts.T(), IsNotFoundError(err))
526+
})
527+
528+
ts.Run("an empty token does not match a provider without one", func() {
529+
found, err := FindSSOProviderBySCIMToken(ts.db, "")
530+
531+
require.Nil(ts.T(), found)
532+
require.True(ts.T(), IsNotFoundError(err))
533+
})
534+
535+
ts.Run("rotation stops the previous token from resolving", func() {
536+
newToken := "scim_rotated_token"
537+
provider.UpdateSCIMToken(newToken)
538+
require.NoError(ts.T(), ts.db.Update(provider))
539+
540+
found, err := FindSSOProviderBySCIMToken(ts.db, newToken)
541+
require.NoError(ts.T(), err)
542+
require.Equal(ts.T(), provider.ID, found.ID)
543+
544+
found, err = FindSSOProviderBySCIMToken(ts.db, token)
545+
require.Nil(ts.T(), found)
546+
require.True(ts.T(), IsNotFoundError(err))
547+
})
548+
}
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)