package models import ( "strings" "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/storage" ) // GetAccountLinkingDomain returns a string that describes the account linking // domain. An account linking domain describes a set of Identity entities that // _should_ generally fall under the same User entity. It's just a runtime // string, and is not typically persisted in the database. This value can vary // across time. func GetAccountLinkingDomain(provider string) string { if strings.HasPrefix(provider, "sso:") { // when the provider ID is a SSO provider, then the linking // domain is the provider itself i.e. there can only be one // user + identity per identity provider return provider } // otherwise, the linking domain is the default linking domain that // links all accounts return "default" } type AccountLinkingDecision = int const ( AccountExists AccountLinkingDecision = iota CreateAccount LinkAccount MultipleAccounts ) type AccountLinkingResult struct { Decision AccountLinkingDecision User *User Identities []*Identity LinkingDomain string CandidateEmail provider.Email } // DetermineAccountLinking uses the provided data and database state to compute a decision on whether: // - A new User should be created (CreateAccount) // - A new Identity should be created (LinkAccount) with a UserID pointing to an existing user account // - Nothing should be done (AccountExists) // - It's not possible to decide due to data inconsistency (MultipleAccounts) and the caller should decide // // Errors signal failure in processing only, like database access errors. func DetermineAccountLinking(tx *storage.Connection, config *conf.GlobalConfiguration, emails []provider.Email, aud, providerName, sub string) (AccountLinkingResult, error) { var verifiedEmails []string var candidateEmail provider.Email for _, email := range emails { if email.Verified || config.Mailer.Autoconfirm { verifiedEmails = append(verifiedEmails, strings.ToLower(email.Email)) } if email.Primary { candidateEmail = email candidateEmail.Email = strings.ToLower(email.Email) } } if identity, terr := FindIdentityByIdAndProvider(tx, sub, providerName); terr == nil { // account exists var user *User if user, terr = FindUserByID(tx, identity.UserID); terr != nil { return AccountLinkingResult{}, terr } // we overwrite the email with the existing user's email since the user // could have an empty email candidateEmail.Email = user.GetEmail() return AccountLinkingResult{ Decision: AccountExists, User: user, Identities: []*Identity{identity}, LinkingDomain: GetAccountLinkingDomain(providerName), CandidateEmail: candidateEmail, }, nil } else if !IsNotFoundError(terr) { return AccountLinkingResult{}, terr } // the identity does not exist, so we need to check if we should create a new account // or link to an existing one // this is the linking domain for the new identity candidateLinkingDomain := GetAccountLinkingDomain(providerName) if len(verifiedEmails) == 0 { // if there are no verified emails, we always decide to create a new account user, terr := IsDuplicatedEmail(tx, candidateEmail.Email, aud, nil) if terr != nil { return AccountLinkingResult{}, terr } if user != nil { candidateEmail.Email = "" } return AccountLinkingResult{ Decision: CreateAccount, LinkingDomain: candidateLinkingDomain, CandidateEmail: candidateEmail, }, nil } var similarIdentities []*Identity var similarUsers []*User // look for similar identities and users based on email if terr := tx.Q().Eager().Where("email = any (?)", verifiedEmails).All(&similarIdentities); terr != nil { return AccountLinkingResult{}, terr } if !strings.HasPrefix(providerName, "sso:") { // there can be multiple user accounts with the same email when is_sso_user is true // so we just do not consider those similar user accounts if terr := tx.Q().Eager().Where("email = any (?) and is_sso_user = false", verifiedEmails).All(&similarUsers); terr != nil { return AccountLinkingResult{}, terr } } // Need to check if the new identity should be assigned to an // existing user or to create a new user, according to the automatic // linking rules var linkingIdentities []*Identity // now let's see if there are any existing and similar identities in // the same linking domain for _, identity := range similarIdentities { if GetAccountLinkingDomain(identity.Provider) == candidateLinkingDomain { linkingIdentities = append(linkingIdentities, identity) } } if len(linkingIdentities) == 0 { if len(similarUsers) == 1 { // no similarIdentities but a user with the same email exists // so we link this new identity to the user // TODO: Backfill the missing identity for the user return AccountLinkingResult{ Decision: LinkAccount, User: similarUsers[0], Identities: linkingIdentities, LinkingDomain: candidateLinkingDomain, CandidateEmail: candidateEmail, }, nil } else if len(similarUsers) > 1 { // this shouldn't happen since there is a partial unique index on (email and is_sso_user = false) return AccountLinkingResult{ Decision: MultipleAccounts, Identities: linkingIdentities, LinkingDomain: candidateLinkingDomain, CandidateEmail: candidateEmail, }, nil } else { // there are no identities in the linking domain, we have to // create a new identity and new user return AccountLinkingResult{ Decision: CreateAccount, LinkingDomain: candidateLinkingDomain, CandidateEmail: candidateEmail, }, nil } } // there is at least one identity in the linking domain let's do a // sanity check to see if all of the identities in the domain share the // same user ID linkingUserId := linkingIdentities[0].UserID for _, identity := range linkingIdentities { if identity.UserID != linkingUserId { // ok this linking domain has more than one user account // caller should decide what to do return AccountLinkingResult{ Decision: MultipleAccounts, Identities: linkingIdentities, LinkingDomain: candidateLinkingDomain, CandidateEmail: candidateEmail, }, nil } } // there's only one user ID in this linking domain, we can go on and // create a new identity and link it to the existing account var user *User var terr error if user, terr = FindUserByID(tx, linkingUserId); terr != nil { return AccountLinkingResult{}, terr } return AccountLinkingResult{ Decision: LinkAccount, User: user, Identities: linkingIdentities, LinkingDomain: candidateLinkingDomain, CandidateEmail: candidateEmail, }, nil }