Skip to content

Commit 747bf3b

Browse files
authored
feat: experimental own linking domains per provider (#2119)
Experimental feature internal to Supabase. Don't use outside of Supabase. Adds the ability to specify providers e.g. `google`, `apple`, whose identities will not participate in the `default` email-similarity identity linking algorithm but be on its own. You can do this by setting ``` GOTRUE_EXPERIMENTAL_PROVIDERS_WITH_OWN_LINKING_DOMAIN="google,apple,..." ``` This has the effect of setting the `is_sso_user` column on `auth.users` to true, which is the only mechanism that allows the same email address to appear in different linking domains. The column cannot be renamed as that requires re-creating a partial index, which is not on the table for this change. It will be revised in the future.
1 parent ca5792e commit 747bf3b

File tree

10 files changed

+48
-27
lines changed

10 files changed

+48
-27
lines changed

cmd/admin_cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ func adminCreateUser(config *conf.GlobalConfiguration, args []string) {
6666
defer db.Close()
6767

6868
aud := getAudience(config)
69-
if user, err := models.IsDuplicatedEmail(db, args[0], aud, nil); user != nil {
69+
if user, err := models.IsDuplicatedEmail(db, args[0], aud, nil, config.Experimental.ProvidersWithOwnLinkingDomain); user != nil {
7070
logrus.Fatalf("Error creating new user: user already exists")
7171
} else if err != nil {
7272
logrus.Fatalf("Error checking user email: %+v", err)

internal/api/admin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
348348
if err != nil {
349349
return err
350350
}
351-
if user, err := models.IsDuplicatedEmail(db, params.Email, aud, nil); err != nil {
351+
if user, err := models.IsDuplicatedEmail(db, params.Email, aud, nil, config.Experimental.ProvidersWithOwnLinkingDomain); err != nil {
352352
return apierrors.NewInternalServerError("Database error checking email").WithInternalError(err)
353353
} else if user != nil {
354354
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeEmailExists, DuplicateEmailMsg)

internal/api/external.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -334,10 +334,17 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.
334334
Data: identityData,
335335
}
336336

337-
isSSOUser := false
338-
if strings.HasPrefix(decision.LinkingDomain, "sso:") {
339-
isSSOUser = true
340-
}
337+
// This is a little bit of a hack. Let me explain: When
338+
// is_sso_user == true, it allows there to be different user
339+
// rows with the same email address. Initially it was added to
340+
// support SSO accounts, but at this point renaming the column
341+
// or adding a new one requires re-indexing the table which is
342+
// expensive and introduces a potentially unnecessary API
343+
// surface change. It therefore set to true for other linking
344+
// domains, not just SSO ones. This enables different linking
345+
// domains to co-exist, such as when using
346+
// GOTRUE_EXPERIMENTAL_PROVIDERS_WITH_OWN_LINKING_DOMAIN="provider_a,provider_b".
347+
isSSOUser := decision.LinkingDomain != "default"
341348

342349
// because params above sets no password, this method is not
343350
// computationally hard so it can be used within a database

internal/api/mail.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error {
239239
if terr != nil {
240240
return terr
241241
}
242-
if duplicateUser, terr := models.IsDuplicatedEmail(tx, params.NewEmail, user.Aud, user); terr != nil {
242+
if duplicateUser, terr := models.IsDuplicatedEmail(tx, params.NewEmail, user.Aud, user, config.Experimental.ProvidersWithOwnLinkingDomain); terr != nil {
243243
return apierrors.NewInternalServerError("Database error checking email").WithInternalError(terr)
244244
} else if duplicateUser != nil {
245245
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeEmailExists, DuplicateEmailMsg)

internal/api/signup.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
146146
if err != nil {
147147
return err
148148
}
149-
user, err = models.IsDuplicatedEmail(db, params.Email, params.Aud, nil)
149+
user, err = models.IsDuplicatedEmail(db, params.Email, params.Aud, nil, config.Experimental.ProvidersWithOwnLinkingDomain)
150150
case "phone":
151151
if !config.External.Phone.Enabled {
152152
return apierrors.NewBadRequestError(apierrors.ErrorCodePhoneProviderDisabled, "Phone signups are disabled")

internal/api/user.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
115115
}
116116
}
117117

118+
// TODO: Check if a user is SSO via rows in identities table, not via this flag.
118119
if user.IsSSOUser {
119120
updatingForbiddenFields := false
120121

@@ -129,7 +130,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
129130
}
130131

131132
if params.Email != "" && user.GetEmail() != params.Email {
132-
if duplicateUser, err := models.IsDuplicatedEmail(db, params.Email, aud, user); err != nil {
133+
if duplicateUser, err := models.IsDuplicatedEmail(db, params.Email, aud, user, config.Experimental.ProvidersWithOwnLinkingDomain); err != nil {
133134
return apierrors.NewInternalServerError("Database error checking email").WithInternalError(err)
134135
} else if duplicateUser != nil {
135136
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeEmailExists, DuplicateEmailMsg)

internal/conf/configuration.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,13 @@ type AuditLogConfiguration struct {
254254
DisablePostgres bool `split_words:"true" default:"false"`
255255
}
256256

257+
type ExperimentalConfiguration struct {
258+
// Names of providers (e.g. "google") which have their own identity
259+
// linking domain, meaning that the ones listed here _will not
260+
// participate_ in email similarity linking with other accounts.
261+
ProvidersWithOwnLinkingDomain []string `split_words:"true"`
262+
}
263+
257264
// GlobalConfiguration holds all the configuration that applies to all instances.
258265
type GlobalConfiguration struct {
259266
API APIConfiguration
@@ -293,6 +300,8 @@ type GlobalConfiguration struct {
293300
MFA MFAConfiguration `json:"MFA"`
294301
SAML SAMLConfiguration `json:"saml"`
295302
CORS CORSConfiguration `json:"cors"`
303+
304+
Experimental ExperimentalConfiguration `json:"experimental"`
296305
}
297306

298307
type CORSConfiguration struct {

internal/models/linking.go

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package models
22

33
import (
4+
"slices"
45
"strings"
56

67
"github.com/supabase/auth/internal/api/provider"
@@ -13,8 +14,8 @@ import (
1314
// _should_ generally fall under the same User entity. It's just a runtime
1415
// string, and is not typically persisted in the database. This value can vary
1516
// across time.
16-
func GetAccountLinkingDomain(provider string) string {
17-
if strings.HasPrefix(provider, "sso:") {
17+
func GetAccountLinkingDomain(provider string, ownLinkingDomains []string) string {
18+
if strings.HasPrefix(provider, "sso:") || slices.Contains(ownLinkingDomains, provider) {
1819
// when the provider ID is a SSO provider, then the linking
1920
// domain is the provider itself i.e. there can only be one
2021
// user + identity per identity provider
@@ -63,6 +64,9 @@ func DetermineAccountLinking(tx *storage.Connection, config *conf.GlobalConfigur
6364
}
6465
}
6566

67+
// this is the linking domain for the new identity
68+
candidateLinkingDomain := GetAccountLinkingDomain(providerName, config.Experimental.ProvidersWithOwnLinkingDomain)
69+
6670
if identity, terr := FindIdentityByIdAndProvider(tx, sub, providerName); terr == nil {
6771
// account exists
6872

@@ -78,7 +82,7 @@ func DetermineAccountLinking(tx *storage.Connection, config *conf.GlobalConfigur
7882
Decision: AccountExists,
7983
User: user,
8084
Identities: []*Identity{identity},
81-
LinkingDomain: GetAccountLinkingDomain(providerName),
85+
LinkingDomain: candidateLinkingDomain,
8286
CandidateEmail: candidateEmail,
8387
}, nil
8488
} else if !IsNotFoundError(terr) {
@@ -87,12 +91,9 @@ func DetermineAccountLinking(tx *storage.Connection, config *conf.GlobalConfigur
8791

8892
// the identity does not exist, so we need to check if we should create a new account
8993
// or link to an existing one
90-
91-
// this is the linking domain for the new identity
92-
candidateLinkingDomain := GetAccountLinkingDomain(providerName)
9394
if len(verifiedEmails) == 0 {
9495
// if there are no verified emails, we always decide to create a new account
95-
user, terr := IsDuplicatedEmail(tx, candidateEmail.Email, aud, nil)
96+
user, terr := IsDuplicatedEmail(tx, candidateEmail.Email, aud, nil, config.Experimental.ProvidersWithOwnLinkingDomain)
9697
if terr != nil {
9798
return AccountLinkingResult{}, terr
9899
}
@@ -108,12 +109,13 @@ func DetermineAccountLinking(tx *storage.Connection, config *conf.GlobalConfigur
108109

109110
var similarIdentities []*Identity
110111
var similarUsers []*User
112+
111113
// look for similar identities and users based on email
112114
if terr := tx.Q().Eager().Where("email = any (?)", verifiedEmails).All(&similarIdentities); terr != nil {
113115
return AccountLinkingResult{}, terr
114116
}
115117

116-
if !strings.HasPrefix(providerName, "sso:") {
118+
if candidateLinkingDomain == "default" {
117119
// there can be multiple user accounts with the same email when is_sso_user is true
118120
// so we just do not consider those similar user accounts
119121
if terr := tx.Q().Eager().Where("email = any (?) and is_sso_user = false", verifiedEmails).All(&similarUsers); terr != nil {
@@ -129,7 +131,7 @@ func DetermineAccountLinking(tx *storage.Connection, config *conf.GlobalConfigur
129131
// now let's see if there are any existing and similar identities in
130132
// the same linking domain
131133
for _, identity := range similarIdentities {
132-
if GetAccountLinkingDomain(identity.Provider) == candidateLinkingDomain {
134+
if GetAccountLinkingDomain(identity.Provider, config.Experimental.ProvidersWithOwnLinkingDomain) == candidateLinkingDomain {
133135
linkingIdentities = append(linkingIdentities, identity)
134136
}
135137
}

internal/models/user.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -716,9 +716,11 @@ func FindUsersInAudience(tx *storage.Connection, aud string, pageParams *Paginat
716716
return users, err
717717
}
718718

719-
// IsDuplicatedEmail returns whether a user exists with a matching email and audience.
719+
// IsDuplicatedEmail returns whether a user exists with a matching email and
720+
// audience importantly in the *default* identity linking domain (meaning SSO
721+
// accounts and similar are not considered).
720722
// If a currentUser is provided, we will need to filter out any identities that belong to the current user.
721-
func IsDuplicatedEmail(tx *storage.Connection, email, aud string, currentUser *User) (*User, error) {
723+
func IsDuplicatedEmail(tx *storage.Connection, email, aud string, currentUser *User, ownDomainProviders []string) (*User, error) {
722724
var identities []Identity
723725

724726
if err := tx.Eager().Q().Where("email = ?", strings.ToLower(email)).All(&identities); err != nil {
@@ -732,7 +734,7 @@ func IsDuplicatedEmail(tx *storage.Connection, email, aud string, currentUser *U
732734
userIDs := make(map[string]uuid.UUID)
733735
for _, identity := range identities {
734736
if _, ok := userIDs[identity.UserID.String()]; !ok {
735-
if !identity.IsForSSOProvider() {
737+
if GetAccountLinkingDomain(identity.Provider, ownDomainProviders) == "default" {
736738
userIDs[identity.UserID.String()] = identity.UserID
737739
}
738740
}

internal/models/user_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,19 +163,19 @@ func (ts *UserTestSuite) TestFindUserWithRefreshToken() {
163163
func (ts *UserTestSuite) TestIsDuplicatedEmail() {
164164
_ = ts.createUserWithEmail("[email protected]")
165165

166-
e, err := IsDuplicatedEmail(ts.db, "[email protected]", "test", nil)
166+
e, err := IsDuplicatedEmail(ts.db, "[email protected]", "test", nil, nil)
167167
require.NoError(ts.T(), err)
168168
require.NotNil(ts.T(), e, "expected email to be duplicated")
169169

170-
e, err = IsDuplicatedEmail(ts.db, "[email protected]", "test", nil)
170+
e, err = IsDuplicatedEmail(ts.db, "[email protected]", "test", nil, nil)
171171
require.NoError(ts.T(), err)
172-
require.Nil(ts.T(), e, "expected email to not be duplicated", nil)
172+
require.Nil(ts.T(), e, "expected email to not be duplicated", nil, nil)
173173

174-
e, err = IsDuplicatedEmail(ts.db, "[email protected]", "test", nil)
174+
e, err = IsDuplicatedEmail(ts.db, "[email protected]", "test", nil, nil)
175175
require.NoError(ts.T(), err)
176-
require.Nil(ts.T(), e, "expected same email to not be duplicated", nil)
176+
require.Nil(ts.T(), e, "expected same email to not be duplicated", nil, nil)
177177

178-
e, err = IsDuplicatedEmail(ts.db, "[email protected]", "other-aud", nil)
178+
e, err = IsDuplicatedEmail(ts.db, "[email protected]", "other-aud", nil, nil)
179179
require.NoError(ts.T(), err)
180180
require.Nil(ts.T(), e, "expected same email to not be duplicated")
181181
}

0 commit comments

Comments
 (0)