Repository: supabase/gotrue
Pull request #850 (2022-12-08 14:46:16)

fix: fill `last_sign_in_at` with a non-null value on backfilled email identities
With the email identities backfill migration, new `email` identities were created where `last_sign_in_at` was set to null. Even though the database allows this, the types in gotrue-js do not, which crashes strongly-typed derivations of the library like Flutter projects.


Patch:
diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml
deleted file mode 100644
index 9826ab94..00000000
--- a/.github/workflows/mirror.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: Mirror Image
-
-on:
-  workflow_dispatch:
-    inputs:
-      version:
-        description: "Image tag"
-        required: true
-        type: string
-
-jobs:
-  mirror:
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      packages: write
-    steps:
-      - uses: docker/login-action@v2
-        with:
-          registry: public.ecr.aws
-          username: ${{ secrets.PROD_ACCESS_KEY_ID }}
-          password: ${{ secrets.PROD_SECRET_ACCESS_KEY }}
-      - uses: docker/login-action@v2
-        with:
-          registry: ghcr.io
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-      - uses: akhilerm/tag-push-action@v2.1.0
-        with:
-          src: docker.io/supabase/gotrue:${{ inputs.version }}
-          dst: |
-            public.ecr.aws/supabase/gotrue:${{ inputs.version }}
-            ghcr.io/supabase/gotrue:${{ inputs.version }}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index b31989fd..f5c58e28 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -13,17 +13,13 @@ on:
 jobs:
   publish:
     runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      packages: write
     steps:
       - id: meta
         uses: docker/metadata-action@v4
         with:
           images: |
             supabase/gotrue
-            public.ecr.aws/supabase/gotrue
-            ghcr.io/supabase/gotrue
+            public.ecr.aws/t3w2s2c9/gotrue
             436098097459.dkr.ecr.us-east-1.amazonaws.com/gotrue
             646182064048.dkr.ecr.us-east-1.amazonaws.com/gotrue
           flavor: |
@@ -64,13 +60,6 @@ jobs:
           username: ${{ secrets.PROD_ACCESS_KEY_ID }}
           password: ${{ secrets.PROD_SECRET_ACCESS_KEY }}
 
-      - name: Login to GHCR
-        uses: docker/login-action@v2
-        with:
-          registry: ghcr.io
-          username: ${{ github.actor }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-
       - uses: docker/build-push-action@v3
         with:
           push: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b65b5b5b..307fb420 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -10,6 +10,7 @@ on:
       - master
       - mfa
       - zerosessionidfix
+      - lastsigninatfix
 
 jobs:
   release:
@@ -18,7 +19,6 @@ jobs:
       version: ${{ steps.semantic.outputs.new_release_version }}
 
     runs-on: ubuntu-20.04
-    if: ${{ github.event.workflow_run.conclusion == 'success' }}
     steps:
       - uses: actions/checkout@v3
       - name: Release on GitHub
diff --git a/.releaserc b/.releaserc
index 9a1cc36c..14c76fb6 100644
--- a/.releaserc
+++ b/.releaserc
@@ -1,6 +1,10 @@
 {
   "branches": [
     "master",
+    {
+        "name": "lastsigninatfix",
+        "prerelease": true
+    },
     {
         "name": "mfa",
         "prerelease": true
diff --git a/api/admin.go b/api/admin.go
index 9d2e7ee4..dd860121 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -275,9 +275,9 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
 		if err != nil {
 			return err
 		}
-		if user, err := models.IsDuplicatedEmail(db, params.Email, aud); err != nil {
+		if exists, err := models.IsDuplicatedEmail(db, params.Email, aud); err != nil {
 			return internalServerError("Database error checking email").WithInternalError(err)
-		} else if user != nil {
+		} else if exists {
 			return unprocessableEntityError("Email address already registered by another user")
 		}
 	}
diff --git a/api/external.go b/api/external.go
index ea75697e..09ae5984 100644
--- a/api/external.go
+++ b/api/external.go
@@ -13,6 +13,7 @@ import (
 	"github.com/gofrs/uuid"
 	jwt "github.com/golang-jwt/jwt"
 	"github.com/netlify/gotrue/api/provider"
+	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/models"
 	"github.com/netlify/gotrue/observability"
 	"github.com/netlify/gotrue/storage"
@@ -157,7 +158,7 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 				return terr
 			}
 		} else {
-			if user, terr = a.createAccountFromExternalIdentity(tx, r, userData, providerType); terr != nil {
+			if user, terr = a.createAccountFromExternalIdentity(tx, r, userData, providerType, user); terr != nil {
 				if errors.Is(terr, errReturnNil) {
 					return nil
 				}
@@ -199,91 +200,75 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 	return nil
 }
 
-func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.Request, userData *provider.UserProvidedData, providerType string) (*models.User, error) {
+func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.Request, userData *provider.UserProvidedData, providerType string, user *models.User) (*models.User, error) {
 	ctx := r.Context()
 	aud := a.requestAud(ctx, r)
 	config := a.config
 
 	var terr error
 
-	var user *models.User
-	var identity *models.Identity
-
 	var emailData provider.Email
 	var identityData map[string]interface{}
 	if userData.Metadata != nil {
 		identityData = structs.Map(userData.Metadata)
 	}
 
-	var emails []string
-
-	for _, email := range userData.Emails {
-		if email.Verified || config.Mailer.Autoconfirm {
-			emails = append(emails, email.Email)
-		}
-	}
-
-	decision, terr := models.DetermineAccountLinking(tx, providerType, userData.Metadata.Subject, emails)
-	if terr != nil {
-		return nil, terr
-	}
-
-	switch decision.Decision {
-	case models.LinkAccount:
-		user = decision.User
-
-		emailData = userData.Emails[0]
-		for _, e := range userData.Emails {
-			if e.Primary || e.Verified {
-				emailData = e
-				break
+	var identity *models.Identity
+	// check if identity exists
+	if identity, terr = models.FindIdentityByIdAndProvider(tx, userData.Metadata.Subject, providerType); terr != nil {
+		if models.IsNotFoundError(terr) {
+			user, emailData, terr = a.getUserByVerifiedEmail(tx, config, userData.Emails, aud)
+			if terr != nil && !models.IsNotFoundError(terr) {
+				return nil, internalServerError("Error checking for existing users").WithInternalError(terr)
 			}
-		}
+			if user != nil {
+				if identity, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil {
+					return nil, terr
+				}
+				if terr = user.UpdateAppMetaDataProviders(tx); terr != nil {
+					return nil, terr
+				}
+			} else {
+				if config.DisableSignup {
+					return nil, forbiddenError("Signups not allowed for this instance")
+				}
 
-		if _, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil {
-			return nil, terr
-		}
+				// prefer primary email for new signups
+				emailData = userData.Emails[0]
+				for _, e := range userData.Emails {
+					if e.Primary {
+						emailData = e
+						break
+					}
+				}
 
-		if terr = user.UpdateAppMetaDataProviders(tx); terr != nil {
-			return nil, terr
-		}
+				params := &SignupParams{
+					Provider: providerType,
+					Email:    emailData.Email,
+					Aud:      aud,
+					Data:     identityData,
+				}
 
-	case models.CreateAccount:
-		if config.DisableSignup {
-			return nil, forbiddenError("Signups not allowed for this instance")
-		}
+				user, terr = a.signupNewUser(ctx, tx, params)
+				if terr != nil {
+					return nil, terr
+				}
 
-		// prefer primary email for new signups
-		emailData = userData.Emails[0]
-		for _, e := range userData.Emails {
-			if e.Primary {
-				emailData = e
-				break
+				if identity, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil {
+					return nil, terr
+				}
 			}
-		}
-
-		params := &SignupParams{
-			Provider: providerType,
-			Email:    emailData.Email,
-			Aud:      aud,
-			Data:     identityData,
-		}
-
-		isSSOUser := strings.HasPrefix(providerType, "sso:")
-
-		user, terr = a.signupNewUser(ctx, tx, params, isSSOUser)
-		if terr != nil {
+		} else {
 			return nil, terr
 		}
+	}
 
-		if _, terr = a.createNewIdentity(tx, user, providerType, identityData); terr != nil {
+	if identity != nil && user == nil {
+		// get user associated with identity
+		user, terr = models.FindUserByID(tx, identity.UserID)
+		if terr != nil {
 			return nil, terr
 		}
-
-	case models.AccountExists:
-		user = decision.User
-		identity = decision.Identities[0]
-
 		identity.IdentityData = identityData
 		if terr = tx.UpdateOnly(identity, "identity_data", "last_sign_in_at"); terr != nil {
 			return nil, terr
@@ -299,12 +284,6 @@ func (a *API) createAccountFromExternalIdentity(tx *storage.Connection, r *http.
 		if terr = user.UpdateAppMetaDataProviders(tx); terr != nil {
 			return nil, terr
 		}
-
-	case models.MultipleAccounts:
-		return nil, internalServerError(fmt.Sprintf("Multiple accounts with the same email address in the same linking domain detected: %v", decision.LinkingDomain))
-
-	default:
-		return nil, internalServerError(fmt.Sprintf("Unknown automatic linking decision: %v", decision.Decision))
 	}
 
 	if user.IsBanned() {
@@ -562,3 +541,24 @@ func (a *API) createNewIdentity(tx *storage.Connection, user *models.User, provi
 
 	return identity, nil
 }
+
+// getUserByVerifiedEmail checks if one of the verified emails already belongs to a user
+func (a *API) getUserByVerifiedEmail(tx *storage.Connection, config *conf.GlobalConfiguration, emails []provider.Email, aud string) (*models.User, provider.Email, error) {
+	var user *models.User
+	var emailData provider.Email
+	var err error
+
+	for _, e := range emails {
+		if e.Verified || config.Mailer.Autoconfirm {
+			user, err = models.FindUserByEmailAndAudience(tx, e.Email, aud)
+			if err != nil && !models.IsNotFoundError(err) {
+				return user, emailData, err
+			}
+			if user != nil {
+				emailData = e
+				break
+			}
+		}
+	}
+	return user, emailData, err
+}
diff --git a/api/invite.go b/api/invite.go
index 3fcc447e..280a84a7 100644
--- a/api/invite.go
+++ b/api/invite.go
@@ -54,7 +54,7 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error {
 				Aud:      aud,
 				Provider: "email",
 			}
-			user, err = a.signupNewUser(ctx, tx, &signupParams, false /* <- isSSOUser */)
+			user, err = a.signupNewUser(ctx, tx, &signupParams)
 			if err != nil {
 				return err
 			}
diff --git a/api/mail.go b/api/mail.go
index 887c5300..4cf24f8a 100644
--- a/api/mail.go
+++ b/api/mail.go
@@ -113,7 +113,7 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 					Provider: "email",
 					Aud:      aud,
 				}
-				user, terr = a.signupNewUser(ctx, tx, signupParams, false /* <- isSSOUser */)
+				user, terr = a.signupNewUser(ctx, tx, signupParams)
 				if terr != nil {
 					return terr
 				}
@@ -158,7 +158,7 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 					Provider: "email",
 					Aud:      aud,
 				}
-				user, terr = a.signupNewUser(ctx, tx, signupParams, false /* <- isSSOUser */)
+				user, terr = a.signupNewUser(ctx, tx, signupParams)
 				if terr != nil {
 					return terr
 				}
@@ -182,9 +182,9 @@ func (a *API) GenerateLink(w http.ResponseWriter, r *http.Request) error {
 			if terr != nil {
 				return unprocessableEntityError("The new email address provided is invalid")
 			}
-			if duplicateUser, terr := models.IsDuplicatedEmail(tx, params.NewEmail, user.Aud); terr != nil {
+			if exists, terr := models.IsDuplicatedEmail(tx, params.NewEmail, user.Aud); terr != nil {
 				return internalServerError("Database error checking email").WithInternalError(terr)
-			} else if duplicateUser != nil {
+			} else if exists {
 				return unprocessableEntityError(DuplicateEmailMsg)
 			}
 			now := time.Now()
diff --git a/api/mfa.go b/api/mfa.go
index 2f6559b6..d8536795 100644
--- a/api/mfa.go
+++ b/api/mfa.go
@@ -69,10 +69,6 @@ func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("invalid body: unable to parse JSON").WithInternalError(err)
 	}
 
-	if user.IsSSOUser {
-		return unprocessableEntityError("MFA enrollment only supported for non-SSO users at this time")
-	}
-
 	factorType := params.FactorType
 	if factorType != models.TOTP {
 		return badRequestError("factor_type needs to be totp")
@@ -214,10 +210,6 @@ func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("invalid body: unable to parse JSON").WithInternalError(err)
 	}
 
-	if factor.UserID != user.ID {
-		return internalServerError("user needs to own factor to verify")
-	}
-
 	challenge, err := models.FindChallengeByChallengeID(a.db, params.ChallengeID)
 	if err != nil {
 		if models.IsNotFoundError(err) {
@@ -303,9 +295,6 @@ func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error {
 	if factor.Status == models.FactorStateVerified && session.GetAAL() != models.AAL2.String() {
 		return badRequestError("AAL2 required to unenroll verified factor")
 	}
-	if factor.UserID != user.ID {
-		return internalServerError("user must own factor to unenroll")
-	}
 
 	err = a.db.Transaction(func(tx *storage.Connection) error {
 		var terr error
diff --git a/api/provider/linkedin.go b/api/provider/linkedin.go
index 374b827d..2cd68dfa 100644
--- a/api/provider/linkedin.go
+++ b/api/provider/linkedin.go
@@ -37,14 +37,6 @@ type linkedinUser struct {
 	} `json:"profilePicture"`
 }
 
-func (u *linkedinUser) getAvatarUrl() string {
-	avatarURL := ""
-	if len(u.AvatarURL.DisplayImage.Elements) > 0 {
-		avatarURL = u.AvatarURL.DisplayImage.Elements[0].Identifiers[0].Identifier
-	}
-	return avatarURL
-}
-
 type linkedinName struct {
 	Localized       interface{}    `json:"localized"`
 	PreferredLocale linkedinLocale `json:"preferredLocale"`
@@ -135,19 +127,17 @@ func (g linkedinProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*
 		})
 	}
 
-	avatarURL := u.getAvatarUrl()
-
 	return &UserProvidedData{
 		Metadata: &Claims{
 			Issuer:        g.APIPath,
 			Subject:       u.ID,
 			Name:          strings.TrimSpace(GetName(u.FirstName) + " " + GetName(u.LastName)),
-			Picture:       avatarURL,
+			Picture:       u.AvatarURL.DisplayImage.Elements[0].Identifiers[0].Identifier,
 			Email:         e.Elements[0].HandleTilde.EmailAddress,
 			EmailVerified: true,
 
 			// To be deprecated
-			AvatarURL:  avatarURL,
+			AvatarURL:  u.AvatarURL.DisplayImage.Elements[0].Identifiers[0].Identifier,
 			FullName:   strings.TrimSpace(GetName(u.FirstName) + " " + GetName(u.LastName)),
 			ProviderId: u.ID,
 		},
diff --git a/api/saml.go b/api/saml.go
index d936ff2f..bfc4d2f1 100644
--- a/api/saml.go
+++ b/api/saml.go
@@ -35,8 +35,6 @@ func (a *API) getSAMLServiceProvider(identityProvider *saml.EntityDescriptor, id
 		IDPMetadata:       identityProvider,
 	})
 
-	provider.AuthnNameIDFormat = saml.PersistentNameIDFormat
-
 	return &provider
 }
 
diff --git a/api/samlacs.go b/api/samlacs.go
index f6e82f4c..c6f7827c 100644
--- a/api/samlacs.go
+++ b/api/samlacs.go
@@ -242,8 +242,7 @@ func (a *API) SAMLACS(w http.ResponseWriter, r *http.Request) error {
 		var terr error
 		var user *models.User
 
-		// accounts potentially created via SAML can contain non-unique email addresses in the auth.users table
-		if user, terr = a.createAccountFromExternalIdentity(tx, r, &userProvidedData, "sso:"+ssoProvider.ID.String()); terr != nil {
+		if user, terr = a.createAccountFromExternalIdentity(tx, r, &userProvidedData, "sso:"+ssoProvider.ID.String(), user); terr != nil {
 			return terr
 		}
 
diff --git a/api/samlassertion.go b/api/samlassertion.go
index 2f541c4b..9d456d0e 100644
--- a/api/samlassertion.go
+++ b/api/samlassertion.go
@@ -159,18 +159,9 @@ func (a *SAMLAssertion) NotBefore() time.Time {
 // NotAfter extracts the time at which or after this assertion should not be
 // considered.
 func (a *SAMLAssertion) NotAfter() time.Time {
-	var notOnOrAfter time.Time
-
-	for _, statement := range a.AuthnStatements {
-		if statement.SessionNotOnOrAfter == nil {
-			continue
-		}
-
-		notOnOrAfter = *statement.SessionNotOnOrAfter
-		if !notOnOrAfter.IsZero() {
-			break
-		}
+	if a.Conditions != nil && !a.Conditions.NotOnOrAfter.IsZero() {
+		return a.Conditions.NotOnOrAfter.UTC()
 	}
 
-	return notOnOrAfter
+	return time.Time{}
 }
diff --git a/api/signup.go b/api/signup.go
index 25a9cc6e..00cc65a1 100644
--- a/api/signup.go
+++ b/api/signup.go
@@ -79,7 +79,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 		if err != nil {
 			return err
 		}
-		user, err = models.IsDuplicatedEmail(db, params.Email, params.Aud)
+		user, err = models.FindUserByEmailAndAudience(db, params.Email, params.Aud)
 	case "phone":
 		if !config.External.Phone.Enabled {
 			return badRequestError("Phone signups are disabled")
@@ -106,7 +106,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
 
 			// do not update the user because we can't be sure of their claimed identity
 		} else {
-			user, terr = a.signupNewUser(ctx, tx, params, false /* <- isSSOUser */)
+			user, terr = a.signupNewUser(ctx, tx, params)
 			if terr != nil {
 				return terr
 			}
@@ -279,7 +279,7 @@ func sanitizeUser(u *models.User, params *SignupParams) (*models.User, error) {
 	return u, nil
 }
 
-func (a *API) signupNewUser(ctx context.Context, conn *storage.Connection, params *SignupParams, isSSOUser bool) (*models.User, error) {
+func (a *API) signupNewUser(ctx context.Context, conn *storage.Connection, params *SignupParams) (*models.User, error) {
 	config := a.config
 
 	var user *models.User
@@ -294,8 +294,6 @@ func (a *API) signupNewUser(ctx context.Context, conn *storage.Connection, param
 		user, err = models.NewUser("", params.Email, params.Password, params.Aud, params.Data)
 	}
 
-	user.IsSSOUser = isSSOUser
-
 	if err != nil {
 		return nil, internalServerError("Database error creating user").WithInternalError(err)
 	}
diff --git a/api/ssoadmin.go b/api/ssoadmin.go
index b26eb9f4..7bacf136 100644
--- a/api/ssoadmin.go
+++ b/api/ssoadmin.go
@@ -6,6 +6,7 @@ import (
 	"io"
 	"net/http"
 	"net/url"
+	"regexp"
 	"unicode/utf8"
 
 	"github.com/crewjam/saml"
@@ -17,31 +18,55 @@ import (
 	"github.com/netlify/gotrue/storage"
 )
 
+var (
+	ssoProviderResourceIDPattern = regexp.MustCompile("(?i)^[a-z0-9_-]{5,256}$")
+)
+
 // loadSSOProvider looks for an idp_id parameter in the URL route and loads the SSO provider
 // with that ID (or resource ID) and adds it to the context.
 func (a *API) loadSSOProvider(w http.ResponseWriter, r *http.Request) (context.Context, error) {
 	ctx := r.Context()
 	db := a.db.WithContext(ctx)
 
+	var err error
+	var provider *models.SSOProvider
+
 	idpParam := chi.URLParam(r, "idp_id")
 
-	idpID, err := uuid.FromString(idpParam)
-	if err != nil {
-		// idpParam is not UUIDv4
-		return nil, notFoundError("SSO Identity Provider not found")
+	if idpID, err := uuid.FromString(idpParam); err == nil {
+		// idpParam is a UUIDv4
+		provider, err = models.FindSSOProviderByID(db, idpID)
+		if err != nil {
+			if !models.IsNotFoundError(err) {
+				return nil, internalServerError("Database error finding SSO Identity Provider").WithInternalError(err)
+			}
+			// not found with the ID, maybe it's a ResourceID?
+		}
 	}
 
-	// idpParam is a UUIDv4
-	provider, err := models.FindSSOProviderByID(db, idpID)
-	if err != nil {
-		if models.IsNotFoundError(err) {
+	if provider == nil {
+		// provider wasn't found, consider idpParam is a resource ID
+
+		if !ssoProviderResourceIDPattern.MatchString(idpParam) {
+			// it is definitely not a resource ID
 			return nil, notFoundError("SSO Identity Provider not found")
-		} else {
-			return nil, internalServerError("Database error finding SSO Identity Provider").WithInternalError(err)
+		}
+
+		provider, err = models.FindSSOProviderByResourceID(db, idpParam)
+		if err != nil {
+			if models.IsNotFoundError(err) {
+				return nil, notFoundError("SSO Identity Provider not found")
+			} else {
+				return nil, internalServerError("Database error finding SSO Identity Provider").WithInternalError(err)
+			}
 		}
 	}
 
 	observability.LogEntrySetField(r, "sso_provider_id", provider.ID.String())
+	if provider.ResourceID != nil {
+		resourceID := *provider.ResourceID
+		observability.LogEntrySetField(r, "sso_provider_resource_id", resourceID)
+	}
 
 	return withSSOProvider(r.Context(), provider), nil
 }
@@ -68,7 +93,8 @@ func (a *API) adminSSOProvidersList(w http.ResponseWriter, r *http.Request) erro
 }
 
 type CreateSSOProviderParams struct {
-	Type string `json:"type"`
+	ResourceID string `json:"resource_id"`
+	Type       string `json:"type"`
 
 	MetadataURL      string                      `json:"metadata_url"`
 	MetadataXML      string                      `json:"metadata_xml"`
@@ -79,6 +105,8 @@ type CreateSSOProviderParams struct {
 func (p *CreateSSOProviderParams) validate(forUpdate bool) error {
 	if !forUpdate && p.Type != "saml" {
 		return badRequestError("Only 'saml' supported for SSO provider type")
+	} else if p.ResourceID != "" && !ssoProviderResourceIDPattern.MatchString(p.ResourceID) {
+		return badRequestError("Resource IDs can only contain letters, digits, dashes and underscores, have a minimum lenth of 5 and a maximum of 256")
 	} else if p.MetadataURL != "" && p.MetadataXML != "" {
 		return badRequestError("Only one of metadata_xml or metadata_url needs to be set")
 	} else if !forUpdate && p.MetadataURL == "" && p.MetadataXML == "" {
@@ -202,6 +230,16 @@ func (a *API) adminSSOProvidersCreate(w http.ResponseWriter, r *http.Request) er
 		return err
 	}
 
+	if params.ResourceID != "" {
+		if _, err := models.FindSSOProviderByResourceID(db, params.ResourceID); err != nil {
+			if !models.IsNotFoundError(err) {
+				return internalServerError("Unable to find SSO provider by resource ID").WithInternalError(err)
+			}
+		} else {
+			return badRequestError("A SSO provider with this resource ID (%s) already exists", params.ResourceID)
+		}
+	}
+
 	existingProvider, err := models.FindSAMLProviderByEntityID(db, metadata.EntityID)
 	if err != nil && !models.IsNotFoundError(err) {
 		return err
@@ -218,6 +256,10 @@ func (a *API) adminSSOProvidersCreate(w http.ResponseWriter, r *http.Request) er
 		},
 	}
 
+	if params.ResourceID != "" {
+		provider.ResourceID = &params.ResourceID
+	}
+
 	if params.MetadataURL != "" {
 		provider.SAMLProvider.MetadataURL = &params.MetadataURL
 	}
@@ -239,11 +281,7 @@ func (a *API) adminSSOProvidersCreate(w http.ResponseWriter, r *http.Request) er
 	}
 
 	if err := db.Transaction(func(tx *storage.Connection) error {
-		if terr := tx.Eager().Create(provider); terr != nil {
-			return terr
-		}
-
-		return tx.Eager().Load(provider)
+		return tx.Eager().Create(provider)
 	}); err != nil {
 		return err
 	}
@@ -281,6 +319,22 @@ func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) er
 
 	provider := getSSOProvider(ctx)
 
+	if params.ResourceID != "" && (provider.ResourceID == nil || params.ResourceID != *provider.ResourceID) {
+		// resource ID is being updated
+
+		existingProvider, err := models.FindSSOProviderByResourceID(db, params.ResourceID)
+		if err != nil && !models.IsNotFoundError(err) {
+			return err
+		}
+
+		if existingProvider != nil {
+			return badRequestError("SSO Provider Resource ID (%s) can't be updated, already assigned to another provider (%s)", params.ResourceID, existingProvider.ID.String())
+		}
+
+		provider.ResourceID = &params.ResourceID
+		modified = true
+	}
+
 	if params.MetadataXML != "" || params.MetadataURL != "" {
 		// metadata is being updated
 		rawMetadata, metadata, err := params.metadata(ctx)
@@ -301,7 +355,7 @@ func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) er
 	}
 
 	var createDomains []models.SSODomain
-	keepDomains := make(map[string]bool)
+	keepProviders := make(map[string]bool)
 
 	for _, domain := range params.Domains {
 		existingProvider, err := models.FindSSOProviderByDomain(db, domain)
@@ -310,7 +364,7 @@ func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) er
 		}
 		if existingProvider != nil {
 			if existingProvider.ID == provider.ID {
-				keepDomains[domain] = true
+				keepProviders[domain] = true
 			} else {
 				return badRequestError("SSO domain '%s' already assigned to another provider (%s)", domain, existingProvider.ID.String())
 			}
@@ -326,7 +380,7 @@ func (a *API) adminSSOProvidersUpdate(w http.ResponseWriter, r *http.Request) er
 	var deleteDomains []models.SSODomain
 
 	for _, domain := range provider.SSODomains {
-		if !keepDomains[domain.Domain] {
+		if !keepProviders[domain.Domain] {
 			modified = true
 			deleteDomains = append(deleteDomains, domain)
 		}
diff --git a/api/token.go b/api/token.go
index b9ddac19..142c0d18 100644
--- a/api/token.go
+++ b/api/token.go
@@ -474,7 +474,7 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R
 					Data:     claims,
 				}
 
-				user, terr = a.signupNewUser(ctx, tx, signupParams, false /* <- isSSOUser */)
+				user, terr = a.signupNewUser(ctx, tx, signupParams)
 				if terr != nil {
 					return terr
 				}
@@ -571,10 +571,7 @@ func generateAccessToken(tx *storage.Connection, user *models.User, sessionId *u
 		if terr != nil {
 			return "", terr
 		}
-		aal, amr, terr = session.CalculateAALAndAMR(tx)
-		if terr != nil {
-			return "", terr
-		}
+		aal, amr = session.CalculateAALAndAMR()
 	}
 
 	claims := &GoTrueClaims{
@@ -675,11 +672,7 @@ func (a *API) updateMFASessionAndClaims(r *http.Request, tx *storage.Connection,
 		if terr != nil {
 			return terr
 		}
-		aal, _, terr := session.CalculateAALAndAMR(tx)
-		if terr != nil {
-			return terr
-		}
-
+		aal, _ := session.CalculateAALAndAMR()
 		if err := session.UpdateAssociatedFactor(tx, grantParams.FactorID); err != nil {
 			return err
 		}
diff --git a/api/user.go b/api/user.go
index 62e4b1ef..b56c819b 100644
--- a/api/user.go
+++ b/api/user.go
@@ -59,12 +59,6 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 	log := observability.GetLogEntry(r)
 	log.Debugf("Checking params for token %v", params)
 
-	if user.IsSSOUser {
-		if (params.Password != nil && *params.Password != "") || params.Email != "" || params.Phone != "" || params.Nonce != "" {
-			return unprocessableEntityError("Updating email, phone, password of a SSO account only possible via SSO")
-		}
-	}
-
 	err = db.Transaction(func(tx *storage.Connection) error {
 		var terr error
 		if params.Password != nil {
@@ -129,10 +123,10 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 				return terr
 			}
 
-			var duplicateUser *models.User
-			if duplicateUser, terr = models.IsDuplicatedEmail(tx, params.Email, user.Aud); terr != nil {
+			var exists bool
+			if exists, terr = models.IsDuplicatedEmail(tx, params.Email, user.Aud); terr != nil {
 				return internalServerError("Database error checking email").WithInternalError(terr)
-			} else if duplicateUser != nil {
+			} else if exists {
 				return unprocessableEntityError(DuplicateEmailMsg)
 			}
 
diff --git a/cmd/admin_cmd.go b/cmd/admin_cmd.go
index 599fc842..70719837 100644
--- a/cmd/admin_cmd.go
+++ b/cmd/admin_cmd.go
@@ -73,7 +73,7 @@ func adminCreateUser(config *conf.GlobalConfiguration, args []string) {
 	defer db.Close()
 
 	aud := getAudience(config)
-	if user, err := models.IsDuplicatedEmail(db, args[0], aud); user != nil {
+	if exists, err := models.IsDuplicatedEmail(db, args[0], aud); exists {
 		logrus.Fatalf("Error creating new user: user already exists")
 	} else if err != nil {
 		logrus.Fatalf("Error checking user email: %+v", err)
diff --git a/go.mod b/go.mod
index 8158d2e5..5f1c4490 100644
--- a/go.mod
+++ b/go.mod
@@ -23,6 +23,7 @@ require (
 	github.com/joho/godotenv v1.3.0
 	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/lestrrat-go/jwx v0.9.0
+	github.com/lib/pq v1.10.1 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.19 // indirect
 	github.com/mitchellh/mapstructure v1.1.2
 	github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450
@@ -65,8 +66,7 @@ require (
 )
 
 require (
-	github.com/crewjam/saml v0.4.9
-	github.com/fatih/structs v1.1.0
+	github.com/crewjam/saml v0.4.8
 	github.com/jackc/pgx/v4 v4.11.0
 )
 
@@ -79,6 +79,7 @@ require (
 	github.com/crewjam/httperr v0.2.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/fatih/color v1.10.0 // indirect
+	github.com/fatih/structs v1.1.0 // indirect
 	github.com/felixge/httpsnoop v1.0.3 // indirect
 	github.com/go-logr/logr v1.2.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
@@ -135,6 +136,7 @@ require (
 	golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 // indirect
+	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	google.golang.org/appengine v1.6.6 // indirect
 	google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1 // indirect
 	google.golang.org/grpc v1.46.2 // indirect
diff --git a/go.sum b/go.sum
index 9d628da1..17b67179 100644
--- a/go.sum
+++ b/go.sum
@@ -138,8 +138,8 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo=
 github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4=
-github.com/crewjam/saml v0.4.9 h1:X2jDv4dv3IvfT9t+RhADavzNFAcq3fVxzTCIH3G605U=
-github.com/crewjam/saml v0.4.9/go.mod h1:9Zh6dWPtB3MSzTRt8fIFH60Z351QQ+s7hCU3J/tTlA4=
+github.com/crewjam/saml v0.4.8 h1:XPrpg7DOIqh7AvMXPMRzHylzzb/ltgnfRRilwCgYb5A=
+github.com/crewjam/saml v0.4.8/go.mod h1:9Zh6dWPtB3MSzTRt8fIFH60Z351QQ+s7hCU3J/tTlA4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -233,6 +233,8 @@ github.com/gobuffalo/plush/v4 v4.0.0/go.mod h1:ErFS3UxKqEb8fpFJT7lYErfN/Nw6vHGiD
 github.com/gobuffalo/plush/v4 v4.1.0 h1:l39qv4p16mEZIPQX/YyCgkUsYTe/L9otgCoBMlYb4Ms=
 github.com/gobuffalo/plush/v4 v4.1.0/go.mod h1:ErFS3UxKqEb8fpFJT7lYErfN/Nw6vHGiDMTjxpk5bQ0=
 github.com/gobuffalo/pop/v5 v5.2.0/go.mod h1:Hj586Cr7FoTFNmvzyNdUcajv3r0A+W+bkil4RIX/zKo=
+github.com/gobuffalo/pop/v5 v5.3.3 h1:L8TRyREUSO2Jtai3DeqnPTHV0AOAMZYyf6TaVsLBVsc=
+github.com/gobuffalo/pop/v5 v5.3.3/go.mod h1:Ey1hqzDLkWQKNEfsnafaz+3P1h/TrS++W9PmpGsNxvk=
 github.com/gobuffalo/pop/v5 v5.3.4 h1:VWH74icA8khG8SyWDAq6Ch00jcPiC4n436JMFh0lLm0=
 github.com/gobuffalo/pop/v5 v5.3.4/go.mod h1:UiVurv2aTKC7MuR27PnMrQjAazoLr8SoC/LuTKTS/tQ=
 github.com/gobuffalo/tags/v3 v3.0.2/go.mod h1:ZQeN6TCTiwAFnS0dNcbDtSgZDwNKSpqajvVtt6mlYpA=
@@ -386,6 +388,8 @@ github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5
 github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
 github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
 github.com/jackc/pgconn v1.6.0/go.mod h1:yeseQo4xhQbgyJs2c87RAXOH2i624N0Fh1KSPJya7qo=
+github.com/jackc/pgconn v1.8.0 h1:FmjZ0rOyXTr1wfWs45i4a9vjnjWUAGpMuQLD9OSs+lw=
+github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
 github.com/jackc/pgconn v1.8.1 h1:ySBX7Q87vOMqKU2bbmKbUvtYhauDFclYbNDYIE1/h6s=
 github.com/jackc/pgconn v1.8.1/go.mod h1:JV6m6b6jhjdmzchES0drzCcYcAHS1OPD5xu3OZ/lE2g=
 github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451 h1:WAvSpGf7MsFuzAtK4Vk7R4EVe+liW4x83r4oWu0WHKw=
@@ -416,6 +420,8 @@ github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4
 github.com/jackc/pgtype v1.3.0/go.mod h1:b0JqxHvPmljG+HQ5IsvQ0yqeSi4nGcDTVjFoiLDb0Ik=
 github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
 github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
+github.com/jackc/pgtype v1.6.2 h1:b3pDeuhbbzBYcg5kwNmNDun4pFUD/0AAr1kLXZLeNt8=
+github.com/jackc/pgtype v1.6.2/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
 github.com/jackc/pgtype v1.7.0 h1:6f4kVsW01QftE38ufBYxKciO6gyioXSC0ABIRLcZrGs=
 github.com/jackc/pgtype v1.7.0/go.mod h1:ZnHF+rMePVqDKaOfJVI4Q8IVvAQMryDlDkZnKOI75BE=
 github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
@@ -426,6 +432,8 @@ github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXg
 github.com/jackc/pgx/v4 v4.6.0/go.mod h1:vPh43ZzxijXUVJ+t/EmXBtFmbFVO72cuneCT9oAlxAg=
 github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
 github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
+github.com/jackc/pgx/v4 v4.10.1 h1:/6Q3ye4myIj6AaplUm+eRcz4OhK9HAvFf4ePsG40LJY=
+github.com/jackc/pgx/v4 v4.10.1/go.mod h1:QlrWebbs3kqEZPHCTGyxecvzG6tvIsYu+A5b1raylkA=
 github.com/jackc/pgx/v4 v4.11.0 h1:J86tSWd3Y7nKjwT/43xZBvpi04keQWx8gNC2YkdJhZI=
 github.com/jackc/pgx/v4 v4.11.0/go.mod h1:i62xJgdrtVDsnL3U8ekyrQXEwGNTRoG7/8r+CIdYfcc=
 github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
@@ -435,6 +443,8 @@ github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv
 github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
+github.com/jmoiron/sqlx v1.3.1 h1:aLN7YINNZ7cYOPK3QC83dbM6KT0NMqVMw961TqrejlE=
+github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
 github.com/jmoiron/sqlx v1.3.3 h1:j82X0bf7oQ27XeqxicSZsTU5suPwKElg3oyxNn43iTk=
 github.com/jmoiron/sqlx v1.3.3/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
 github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
@@ -486,6 +496,8 @@ github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/lib/pq v1.9.0 h1:L8nSXQQzAYByakOFMTwpjRoHsMJklur4Gi59b6VivR8=
+github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lib/pq v1.10.1 h1:6VXZrLU0jHBYyAqrSPa+MgPfnSvTPuMgK+k0o5kVFWo=
 github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
@@ -1054,6 +1066,7 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
 google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
diff --git a/migrations/20211124214934_update_auth_functions.up.sql b/migrations/20211124214934_update_auth_functions.up.sql
index 2fb784bb..9a13a04e 100644
--- a/migrations/20211124214934_update_auth_functions.up.sql
+++ b/migrations/20211124214934_update_auth_functions.up.sql
@@ -5,10 +5,10 @@ returns uuid
 language sql stable
 as $$
   select 
-  coalesce(
-    current_setting('request.jwt.claim.sub', true),
-    (current_setting('request.jwt.claims', true)::jsonb ->> 'sub')
-  )::uuid
+  	coalesce(
+		current_setting('request.jwt.claim.sub', true),
+		(current_setting('request.jwt.claims', true)::jsonb ->> 'sub')
+	)::uuid
 $$;
 
 create or replace function {{ index .Options "Namespace" }}.role() 
@@ -16,10 +16,10 @@ returns text
 language sql stable
 as $$
   select 
-  coalesce(
-    current_setting('request.jwt.claim.role', true),
-    (current_setting('request.jwt.claims', true)::jsonb ->> 'role')
-  )::text
+  	coalesce(
+		current_setting('request.jwt.claim.role', true),
+		(current_setting('request.jwt.claims', true)::jsonb ->> 'role')
+	)::text
 $$;
 
 create or replace function {{ index .Options "Namespace" }}.email() 
@@ -27,8 +27,8 @@ returns text
 language sql stable
 as $$
   select 
-  coalesce(
-    current_setting('request.jwt.claim.email', true),
-    (current_setting('request.jwt.claims', true)::jsonb ->> 'email')
-  )::text
+  	coalesce(
+		current_setting('request.jwt.claim.email', true),
+		(current_setting('request.jwt.claims', true)::jsonb ->> 'email')
+	)::text
 $$;
diff --git a/migrations/20220224000811_update_auth_functions.up.sql b/migrations/20220224000811_update_auth_functions.up.sql
index 4be42373..fc7f236c 100644
--- a/migrations/20220224000811_update_auth_functions.up.sql
+++ b/migrations/20220224000811_update_auth_functions.up.sql
@@ -5,10 +5,10 @@ returns uuid
 language sql stable
 as $$
   select 
-  coalesce(
-    nullif(current_setting('request.jwt.claim.sub', true), ''),
-    (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
-  )::uuid
+  	coalesce(
+		nullif(current_setting('request.jwt.claim.sub', true), ''),
+		(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
+	)::uuid
 $$;
 
 create or replace function {{ index .Options "Namespace" }}.role() 
@@ -16,10 +16,10 @@ returns text
 language sql stable
 as $$
   select 
-  coalesce(
-    nullif(current_setting('request.jwt.claim.role', true), ''),
-    (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role')
-  )::text
+  	coalesce(
+		nullif(current_setting('request.jwt.claim.role', true), ''),
+		(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role')
+	)::text
 $$;
 
 create or replace function {{ index .Options "Namespace" }}.email() 
@@ -27,8 +27,8 @@ returns text
 language sql stable
 as $$
   select 
-  coalesce(
-    nullif(current_setting('request.jwt.claim.email', true), ''),
-    (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email')
-  )::text
+  	coalesce(
+		nullif(current_setting('request.jwt.claim.email', true), ''),
+		(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email')
+	)::text
 $$;
diff --git a/migrations/20221120114718_add_identities_email_column.up.sql b/migrations/20221120114718_add_identities_email_column.up.sql
deleted file mode 100644
index f47e31cf..00000000
--- a/migrations/20221120114718_add_identities_email_column.up.sql
+++ /dev/null
@@ -1,15 +0,0 @@
-update
-  {{ index .Options "Namespace" }}.identities as identities
-set
-  identity_data = identity_data || jsonb_build_object('email', (select email from {{ index .Options "Namespace" }}.users where id = identities.user_id)),
-  updated_at = now()
-where identities.provider = 'email' and identity_data->>'email' is null;
-
-alter table only {{ index .Options "Namespace" }}.identities
-  add column if not exists email text generated always as (lower(identity_data->>'email')) stored;
-
-comment on column {{ index .Options "Namespace" }}.identities.email is 'Auth: Email is a generated column that references the optional email property in the identity_data';
-
-create index if not exists identities_email_idx on {{ index .Options "Namespace" }}.identities (email text_pattern_ops);
-
-comment on index {{ index .Options "Namespace" }}.identities_email_idx is 'Auth: Ensures indexed queries on the email column';
diff --git a/migrations/20221121110412_modify_users_email_unique_index.up.sql b/migrations/20221121110412_modify_users_email_unique_index.up.sql
deleted file mode 100644
index ef1102c9..00000000
--- a/migrations/20221121110412_modify_users_email_unique_index.up.sql
+++ /dev/null
@@ -1,16 +0,0 @@
--- this change is relatively temporary
--- it is meant to keep database consistency guarantees until there is proper
--- introduction of account linking / merging / delinking APIs, at which point
--- rows in the users table will allow duplicates but with programmatic control
-
-alter table only {{ index .Options "Namespace" }}.users
-  add column if not exists is_sso_user boolean not null default false;
-
-comment on column {{ index .Options "Namespace" }}.users.is_sso_user is 'Auth: Set this column to true when the account comes from SSO. These accounts can have duplicate emails.';
-
-create unique index if not exists users_email_partial_key on {{ index .Options "Namespace" }}.users (email) where (is_sso_user = false);
-
-comment on index {{ index .Options "Namespace" }}.users_email_key is 'Auth: A partial unique index that applies only when is_sso_user is false';
-
-alter table only {{ index .Options "Namespace" }}.users
-  drop constraint if exists users_email_key;
diff --git a/migrations/20221124140122_add_identities_email_column.up.sql b/migrations/20221124140122_add_identities_email_column.up.sql
deleted file mode 100644
index a935e9c2..00000000
--- a/migrations/20221124140122_add_identities_email_column.up.sql
+++ /dev/null
@@ -1,15 +0,0 @@
-update
-  {{ index .Options "Namespace" }}.identities as identities
-set
-  identity_data = identity_data || jsonb_build_object('email', (select email from {{ index .Options "Namespace" }}.users where id = identities.user_id)),
-  updated_at = '2022-11-24'
-where identities.provider = 'email' and identity_data->>'email' is null;
-
-alter table only {{ index .Options "Namespace" }}.identities
-  add column if not exists email text generated always as (lower(identity_data->>'email')) stored;
-
-comment on column {{ index .Options "Namespace" }}.identities.email is 'Auth: Email is a generated column that references the optional email property in the identity_data';
-
-create index if not exists identities_email_idx on {{ index .Options "Namespace" }}.identities (email text_pattern_ops);
-
-comment on index {{ index .Options "Namespace" }}.identities_email_idx is 'Auth: Ensures indexed queries on the email column';
diff --git a/migrations/20221125141029_add_identities_email_column.up.sql b/migrations/20221125141029_add_identities_email_column.up.sql
deleted file mode 100644
index b66a9a2f..00000000
--- a/migrations/20221125141029_add_identities_email_column.up.sql
+++ /dev/null
@@ -1,15 +0,0 @@
-update
-  {{ index .Options "Namespace" }}.identities as identities
-set
-  identity_data = identity_data || jsonb_build_object('email', (select email from {{ index .Options "Namespace" }}.users where id = identities.user_id)),
-  updated_at = '2022-11-25'
-where identities.provider = 'email' and identity_data->>'email' is null;
-
-alter table only {{ index .Options "Namespace" }}.identities
-  add column if not exists email text generated always as (lower(identity_data->>'email')) stored;
-
-comment on column {{ index .Options "Namespace" }}.identities.email is 'Auth: Email is a generated column that references the optional email property in the identity_data';
-
-create index if not exists identities_email_idx on {{ index .Options "Namespace" }}.identities (email text_pattern_ops);
-
-comment on index {{ index .Options "Namespace" }}.identities_email_idx is 'Auth: Ensures indexed queries on the email column';
diff --git a/migrations/20221202110421_remove_sso_sessions.up.sql b/migrations/20221202110421_remove_sso_sessions.up.sql
deleted file mode 100644
index 228302d0..00000000
--- a/migrations/20221202110421_remove_sso_sessions.up.sql
+++ /dev/null
@@ -1,3 +0,0 @@
--- sso_sessions is not used as all of the necessary data is in sessions
-drop table if exists {{ index .Options "Namespace" }}.sso_sessions;
-
diff --git a/migrations/20221208132122_backfill_email_last_sign_in_at.up.sql b/migrations/20221208132122_backfill_email_last_sign_in_at.up.sql
new file mode 100644
index 00000000..ee54bfc5
--- /dev/null
+++ b/migrations/20221208132122_backfill_email_last_sign_in_at.up.sql
@@ -0,0 +1,10 @@
+-- previous backfill migration left last_sign_in_at to be null, which broke some projects
+
+update {{ index .Options "Namespace" }}.identities
+  set last_sign_in_at = '2022-11-25'
+  where
+    last_sign_in_at is null and
+    created_at = '2022-11-25' and
+    updated_at = '2022-11-25' and
+    provider = 'email' and
+    id = user_id::text;
diff --git a/models/connection.go b/models/connection.go
index 1363b352..a372b5fa 100644
--- a/models/connection.go
+++ b/models/connection.go
@@ -45,6 +45,7 @@ func TruncateAll(conn *storage.Connection) error {
 			(&pop.Model{Value: AMRClaim{}}).TableName(),
 			(&pop.Model{Value: SSOProvider{}}).TableName(),
 			(&pop.Model{Value: SSODomain{}}).TableName(),
+			(&pop.Model{Value: SSOSession{}}).TableName(),
 			(&pop.Model{Value: SAMLProvider{}}).TableName(),
 			(&pop.Model{Value: SAMLRelayState{}}).TableName(),
 		}
diff --git a/models/identity.go b/models/identity.go
index cd2d714b..fc391f08 100644
--- a/models/identity.go
+++ b/models/identity.go
@@ -56,10 +56,6 @@ func (i *Identity) BeforeUpdate(tx *pop.Connection) error {
 	return nil
 }
 
-func (i *Identity) IsForSSOProvider() bool {
-	return strings.HasPrefix(i.Provider, "sso:")
-}
-
 // FindIdentityById searches for an identity with the matching provider_id and provider given.
 func FindIdentityByIdAndProvider(tx *storage.Connection, providerId, provider string) (*Identity, error) {
 	identity := &Identity{}
@@ -72,10 +68,10 @@ func FindIdentityByIdAndProvider(tx *storage.Connection, providerId, provider st
 	return identity, nil
 }
 
-// FindIdentitiesByUserID returns all identities associated to a user ID.
-func FindIdentitiesByUserID(tx *storage.Connection, userID uuid.UUID) ([]*Identity, error) {
+// FindIdentitiesByUser returns all identities associated to a user
+func FindIdentitiesByUser(tx *storage.Connection, user *User) ([]*Identity, error) {
 	identities := []*Identity{}
-	if err := tx.Q().Where("user_id = ?", userID).All(&identities); err != nil {
+	if err := tx.Q().Where("user_id = ?", user.ID).All(&identities); err != nil {
 		if errors.Cause(err) == sql.ErrNoRows {
 			return identities, nil
 		}
diff --git a/models/linking.go b/models/linking.go
deleted file mode 100644
index 8c501cf7..00000000
--- a/models/linking.go
+++ /dev/null
@@ -1,152 +0,0 @@
-package models
-
-import (
-	"strings"
-
-	"github.com/netlify/gotrue/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
-}
-
-// 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, provider, sub string, emails []string) (AccountLinkingResult, error) {
-	if identity, terr := FindIdentityByIdAndProvider(tx, sub, provider); terr == nil {
-		// account exists
-
-		var user *User
-		if user, terr = FindUserByID(tx, identity.UserID); terr != nil {
-			return AccountLinkingResult{}, terr
-		}
-
-		return AccountLinkingResult{
-			Decision:      AccountExists,
-			User:          user,
-			Identities:    []*Identity{identity},
-			LinkingDomain: GetAccountLinkingDomain(provider),
-		}, nil
-	} else if !IsNotFoundError(terr) {
-		return AccountLinkingResult{}, terr
-	}
-
-	// account does not exist, identity and user not immediately
-	// identifiable, look for similar identities based on email
-	var similarIdentities []*Identity
-
-	if len(emails) > 0 {
-		if terr := tx.Q().Eager().Where("email in (?)", emails).All(&similarIdentities); terr != nil {
-			return AccountLinkingResult{}, terr
-		}
-	}
-
-	// TODO: determine linking behavior over phone too
-
-	if len(similarIdentities) == 0 {
-		// there are no similar identities, clearly we have to create a new account
-
-		return AccountLinkingResult{
-			Decision:      CreateAccount,
-			LinkingDomain: GetAccountLinkingDomain(provider),
-		}, nil
-	}
-
-	// there are some similar identities, we now need to proceed in
-	// identifying whether this supposed new identity should be assigned to
-	// an existing user or to create a new user, according to the automatic
-	// linking rules
-
-	// this is the linking domain for the new identity
-	newAccountLinkingDomain := GetAccountLinkingDomain(provider)
-
-	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) == newAccountLinkingDomain {
-			linkingIdentities = append(linkingIdentities, identity)
-		}
-	}
-
-	if len(linkingIdentities) == 0 {
-		// there are no identities in the linking domain, we have to
-		// create a new identity and new user
-		return AccountLinkingResult{
-			Decision:      CreateAccount,
-			LinkingDomain: newAccountLinkingDomain,
-		}, 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
-
-	for _, identity := range linkingIdentities {
-		if identity.UserID != linkingIdentities[0].UserID {
-			// ok this linking domain has more than one user account
-			// caller should decide what to do
-
-			return AccountLinkingResult{
-				Decision:      MultipleAccounts,
-				Identities:    linkingIdentities,
-				LinkingDomain: newAccountLinkingDomain,
-			}, 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, linkingIdentities[0].UserID); terr != nil {
-		return AccountLinkingResult{}, terr
-	}
-
-	return AccountLinkingResult{
-		Decision:      LinkAccount,
-		User:          user,
-		Identities:    linkingIdentities,
-		LinkingDomain: newAccountLinkingDomain,
-	}, nil
-}
diff --git a/models/sessions.go b/models/sessions.go
index 29cbdc3a..aebf853c 100644
--- a/models/sessions.go
+++ b/models/sessions.go
@@ -3,7 +3,6 @@ package models
 import (
 	"database/sql"
 	"sort"
-	"strings"
 	"time"
 
 	"github.com/gobuffalo/pop/v5"
@@ -37,7 +36,6 @@ func (aal AuthenticatorAssuranceLevel) String() string {
 type AMREntry struct {
 	Method    string `json:"method"`
 	Timestamp int64  `json:"timestamp"`
-	Provider  string `json:"provider,omitempty"`
 }
 
 type sortAMREntries struct {
@@ -151,7 +149,7 @@ func (s *Session) UpdateAssociatedAAL(tx *storage.Connection, aal string) error
 	return tx.Update(s)
 }
 
-func (s *Session) CalculateAALAndAMR(tx *storage.Connection) (aal string, amr []AMREntry, err error) {
+func (s *Session) CalculateAALAndAMR() (aal string, amr []AMREntry) {
 	amr, aal = []AMREntry{}, AAL1.String()
 	for _, claim := range s.AMRClaims {
 		if *claim.AuthenticationMethod == TOTPSignIn.String() {
@@ -172,30 +170,7 @@ func (s *Session) CalculateAALAndAMR(tx *storage.Connection) (aal string, amr []
 		Array: amr,
 	})
 
-	lastIndex := len(amr) - 1
-
-	if lastIndex > -1 && amr[lastIndex].Method == SSOSAML.String() {
-		// initial AMR claim is from sso/saml, we need to add information
-		// about the provider that was used for the authentication
-		identities, err := FindIdentitiesByUserID(tx, s.UserID)
-		if err != nil {
-			return aal, amr, err
-		}
-
-		if len(identities) == 1 {
-			identity := identities[0]
-
-			if strings.HasPrefix(identity.Provider, "sso:") {
-				amr[lastIndex].Provider = strings.TrimPrefix(identity.Provider, "sso:")
-			}
-		}
-
-		// otherwise we can't identify that this user account has only
-		// one SSO identity, so we are not encoding the provider at
-		// this time
-	}
-
-	return aal, amr, nil
+	return aal, amr
 }
 
 func (s *Session) GetAAL() string {
diff --git a/models/sso.go b/models/sso.go
index 6f1e7c55..1d0b611a 100644
--- a/models/sso.go
+++ b/models/sso.go
@@ -15,7 +15,8 @@ import (
 )
 
 type SSOProvider struct {
-	ID uuid.UUID `db:"id" json:"id"`
+	ID         uuid.UUID `db:"id" json:"id"`
+	ResourceID *string   `db:"resource_id" json:"resource_id,omitempty"`
 
 	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"`
@@ -143,6 +144,28 @@ func (d SSODomain) TableName() string {
 	return "sso_domains"
 }
 
+type SSOSession struct {
+	ID uuid.UUID `db:"id"`
+
+	Session   *Session  `belongs_to:"session"`
+	SessionID uuid.UUID `db:"session_id"`
+
+	SSOProvider   *SSOProvider `belongs_to:"sso_providers"`
+	SSOProviderID uuid.UUID    `db:"sso_provider_id"`
+
+	NotBefore time.Time `db:"not_before"`
+	NotAfter  time.Time `db:"not_after"`
+
+	IdPInitiated bool `db:"idp_initiated"`
+
+	CreatedAt time.Time `db:"created_at" json:"-"`
+	UpdatedAt time.Time `db:"updated_at" json:"-"`
+}
+
+func (s SSOSession) TableName() string {
+	return "sso_sessions"
+}
+
 type SAMLRelayState struct {
 	ID uuid.UUID `db:"id"`
 
@@ -162,6 +185,19 @@ func (s SAMLRelayState) TableName() string {
 	return "saml_relay_states"
 }
 
+func FindSSOProviderByResourceID(tx *storage.Connection, resourceID string) (*SSOProvider, error) {
+	var ssoProvider SSOProvider
+	if err := tx.Eager().Q().Where("resource_id = ?", strings.ToLower(resourceID)).First(&ssoProvider); err != nil {
+		if errors.Cause(err) == sql.ErrNoRows {
+			return nil, SSOProviderNotFoundError{}
+		}
+
+		return nil, errors.Wrap(err, "error finding SSO provider by resource ID")
+	}
+
+	return &ssoProvider, nil
+}
+
 func FindSAMLProviderByEntityID(tx *storage.Connection, entityId string) (*SSOProvider, error) {
 	var samlProvider SAMLProvider
 	if err := tx.Q().Where("entity_id = ?", entityId).First(&samlProvider); err != nil {
diff --git a/models/user.go b/models/user.go
index 69960363..54f8c871 100644
--- a/models/user.go
+++ b/models/user.go
@@ -17,14 +17,12 @@ import (
 type User struct {
 	ID uuid.UUID `json:"id" db:"id"`
 
-	Aud       string             `json:"aud" db:"aud"`
-	Role      string             `json:"role" db:"role"`
-	Email     storage.NullString `json:"email" db:"email"`
-	IsSSOUser bool               `json:"-" db:"is_sso_user"`
-
-	EncryptedPassword string     `json:"-" db:"encrypted_password"`
-	EmailConfirmedAt  *time.Time `json:"email_confirmed_at,omitempty" db:"email_confirmed_at"`
-	InvitedAt         *time.Time `json:"invited_at,omitempty" db:"invited_at"`
+	Aud               string             `json:"aud" db:"aud"`
+	Role              string             `json:"role" db:"role"`
+	Email             storage.NullString `json:"email" db:"email"`
+	EncryptedPassword string             `json:"-" db:"encrypted_password"`
+	EmailConfirmedAt  *time.Time         `json:"email_confirmed_at,omitempty" db:"email_confirmed_at"`
+	InvitedAt         *time.Time         `json:"invited_at,omitempty" db:"invited_at"`
 
 	Phone            storage.NullString `json:"phone" db:"phone"`
 	PhoneConfirmedAt *time.Time         `json:"phone_confirmed_at,omitempty" db:"phone_confirmed_at"`
@@ -379,7 +377,7 @@ func findUser(tx *storage.Connection, query string, args ...interface{}) (*User,
 
 // FindUserByConfirmationToken finds users with the matching confirmation token.
 func FindUserByConfirmationToken(tx *storage.Connection, token string) (*User, error) {
-	user, err := findUser(tx, "confirmation_token = ? and is_sso_user = false", token)
+	user, err := findUser(tx, "confirmation_token = ?", token)
 	if err != nil {
 		return nil, ConfirmationTokenNotFoundError{}
 	}
@@ -388,12 +386,12 @@ func FindUserByConfirmationToken(tx *storage.Connection, token string) (*User, e
 
 // FindUserByEmailAndAudience finds a user with the matching email and audience.
 func FindUserByEmailAndAudience(tx *storage.Connection, email, aud string) (*User, error) {
-	return findUser(tx, "instance_id = ? and LOWER(email) = ? and aud = ? and is_sso_user = false", uuid.Nil, strings.ToLower(email), aud)
+	return findUser(tx, "instance_id = ? and LOWER(email) = ? and aud = ?", uuid.Nil, strings.ToLower(email), aud)
 }
 
 // FindUserByPhoneAndAudience finds a user with the matching email and audience.
 func FindUserByPhoneAndAudience(tx *storage.Connection, phone, aud string) (*User, error) {
-	return findUser(tx, "instance_id = ? and phone = ? and aud = ? and is_sso_user = false", uuid.Nil, phone, aud)
+	return findUser(tx, "instance_id = ? and phone = ? and aud = ?", uuid.Nil, phone, aud)
 }
 
 // FindUserByID finds a user matching the provided ID.
@@ -403,12 +401,12 @@ func FindUserByID(tx *storage.Connection, id uuid.UUID) (*User, error) {
 
 // FindUserByRecoveryToken finds a user with the matching recovery token.
 func FindUserByRecoveryToken(tx *storage.Connection, token string) (*User, error) {
-	return findUser(tx, "recovery_token = ? and is_sso_user = false", token)
+	return findUser(tx, "recovery_token = ?", token)
 }
 
 // FindUserByEmailChangeToken finds a user with the matching email change token.
 func FindUserByEmailChangeToken(tx *storage.Connection, token string) (*User, error) {
-	return findUser(tx, "is_sso_user = false and (email_change_token_current = ? or email_change_token_new = ?)", token, token)
+	return findUser(tx, "email_change_token_current = ? or email_change_token_new = ?", token, token)
 }
 
 // FindUserWithRefreshToken finds a user from the provided refresh token.
@@ -478,7 +476,7 @@ func FindUsersInAudience(tx *storage.Connection, aud string, pageParams *Paginat
 func FindUserByEmailChangeCurrentAndAudience(tx *storage.Connection, email, token, aud string) (*User, error) {
 	return findUser(
 		tx,
-		"instance_id = ? and LOWER(email) = ? and email_change_token_current = ? and aud = ? and is_sso_user = false",
+		"instance_id = ? and LOWER(email) = ? and email_change_token_current = ? and aud = ?",
 		uuid.Nil, strings.ToLower(email), token, aud,
 	)
 }
@@ -487,7 +485,7 @@ func FindUserByEmailChangeCurrentAndAudience(tx *storage.Connection, email, toke
 func FindUserByEmailChangeNewAndAudience(tx *storage.Connection, email, token, aud string) (*User, error) {
 	return findUser(
 		tx,
-		"instance_id = ? and LOWER(email_change) = ? and email_change_token_new = ? and aud = ? and is_sso_user = false",
+		"instance_id = ? and LOWER(email_change) = ? and email_change_token_new = ? and aud = ?",
 		uuid.Nil, strings.ToLower(email), token, aud,
 	)
 }
@@ -506,47 +504,19 @@ func FindUserForEmailChange(tx *storage.Connection, email, token, aud string, se
 
 // FindUserByPhoneChangeAndAudience finds a user with the matching phone change and audience.
 func FindUserByPhoneChangeAndAudience(tx *storage.Connection, phone, aud string) (*User, error) {
-	return findUser(tx, "instance_id = ? and phone_change = ? and aud = ? and is_sso_user = false", uuid.Nil, phone, aud)
+	return findUser(tx, "instance_id = ? and phone_change = ? and aud = ?", uuid.Nil, phone, aud)
 }
 
 // IsDuplicatedEmail returns whether a user exists with a matching email and audience.
-func IsDuplicatedEmail(tx *storage.Connection, email, aud string) (*User, error) {
-	var identities []Identity
-
-	if err := tx.Eager().Q().Where("email = ?", strings.ToLower(email)).All(&identities); err != nil {
-		if errors.Cause(err) == sql.ErrNoRows {
-			return nil, nil
-		}
-
-		return nil, errors.Wrap(err, "unable to find identity by email for duplicates")
-	}
-
-	userIDs := make(map[string]uuid.UUID)
-	for _, identity := range identities {
-		if !identity.IsForSSOProvider() {
-			userIDs[identity.UserID.String()] = identity.UserID
-		}
-	}
-
-	for _, userID := range userIDs {
-		user, err := FindUserByID(tx, userID)
-		if err != nil && !IsNotFoundError(err) {
-			return nil, errors.Wrap(err, "unable to find user from email identity for duplicates")
-		}
-
-		if user.Aud == aud {
-			return user, nil
+func IsDuplicatedEmail(tx *storage.Connection, email, aud string) (bool, error) {
+	_, err := FindUserByEmailAndAudience(tx, email, aud)
+	if err != nil {
+		if IsNotFoundError(err) {
+			return false, nil
 		}
+		return false, err
 	}
-
-	// out of an abundance of caution, if nothing was found via the
-	// identities table we also do a final check on the users table
-	user, err := FindUserByEmailAndAudience(tx, email, aud)
-	if err != nil && !IsNotFoundError(err) {
-		return nil, errors.Wrap(err, "unable to find user email addres for duplicates")
-	}
-
-	return user, nil
+	return true, nil
 }
 
 // IsDuplicatedPhone checks if the phone number already exists in the users table


Test patch:
diff --git a/api/external_linkedin_test.go b/api/external_linkedin_test.go
index cce8b958..cb539248 100644
--- a/api/external_linkedin_test.go
+++ b/api/external_linkedin_test.go
@@ -10,10 +10,9 @@ import (
 )
 
 const (
-	linkedinUser             string = `{"id":"linkedinTestId","firstName":{"localized":{"en_US":"Linkedin"},"preferredLocale":{"country":"US","language":"en"}},"lastName":{"localized":{"en_US":"Test"},"preferredLocale":{"country":"US","language":"en"}},"profilePicture":{"displayImage~":{"elements":[{"identifiers":[{"identifier":"http://example.com/avatar"}]}]}}}`
-	linkedinUserNoProfilePic string = `{"id":"linkedinTestId","firstName":{"localized":{"en_US":"Linkedin"},"preferredLocale":{"country":"US","language":"en"}},"lastName":{"localized":{"en_US":"Test"},"preferredLocale":{"country":"US","language":"en"}},"profilePicture":{"displayImage~":{"elements":[]}}}`
-	linkedinEmail            string = `{"elements": [{"handle": "","handle~": {"emailAddress": "linkedin@example.com"}}]}`
-	linkedinWrongEmail       string = `{"elements": [{"handle": "","handle~": {"emailAddress": "other@example.com"}}]}`
+	linkedinUser       string = `{"id":"linkedinTestId","firstName":{"localized":{"en_US":"Linkedin"},"preferredLocale":{"country":"US","language":"en"}},"lastName":{"localized":{"en_US":"Test"},"preferredLocale":{"country":"US","language":"en"}},"profilePicture":{"displayImage~":{"elements":[{"identifiers":[{"identifier":"http://example.com/avatar"}]}]}}}`
+	linkedinEmail      string = `{"elements": [{"handle": "","handle~": {"emailAddress": "linkedin@example.com"}}]}`
+	linkedinWrongEmail string = `{"elements": [{"handle": "","handle~": {"emailAddress": "other@example.com"}}]}`
 )
 
 func (ts *ExternalTestSuite) TestSignupExternalLinkedin() {
@@ -157,14 +156,3 @@ func (ts *ExternalTestSuite) TestInviteTokenExternalLinkedinErrorWhenEmailDoesnt
 
 	assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
 }
-
-func (ts *ExternalTestSuite) TestSignupExternalLinkedin_MissingProfilePic() {
-	tokenCount, userCount := 0, 0
-	code := "authcode"
-	server := LinkedinTestSignupSetup(ts, &tokenCount, &userCount, code, linkedinUserNoProfilePic, linkedinEmail)
-	defer server.Close()
-
-	u := performAuthorization(ts, "linkedin", code, "")
-
-	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "linkedin@example.com", "Linkedin Test", "linkedinTestId", "")
-}
diff --git a/api/external_test.go b/api/external_test.go
index c0ec4d90..daeadcb9 100644
--- a/api/external_test.go
+++ b/api/external_test.go
@@ -52,13 +52,6 @@ func (ts *ExternalTestSuite) createUser(providerId string, email string, name st
 	ts.Require().NoError(err, "Error making new user")
 	ts.Require().NoError(ts.API.db.Create(u), "Error creating user")
 
-	i, err := models.NewIdentity(u, "email", map[string]interface{}{
-		"sub":   u.ID.String(),
-		"email": email,
-	})
-	ts.Require().NoError(err)
-	ts.Require().NoError(ts.API.db.Create(i), "Error creating identity")
-
 	return u, err
 }
 
diff --git a/api/sso_test.go b/api/sso_test.go
index 7a105e35..3d811709 100644
--- a/api/sso_test.go
+++ b/api/sso_test.go
@@ -82,7 +82,13 @@ func (ts *SSOTestSuite) TestAdminGetSSOProviderNotExist() {
 		URL string
 	}{
 		{
-			URL: "http://localhost/admin/sso/providers/not-a-uuid",
+			URL: "http://localhost/admin/sso/providers/unknown-provider-resource-id",
+		},
+		{
+			URL: "http://localhost/admin/sso/providers/1",
+		},
+		{
+			URL: "http://localhost/admin/sso/providers/0",
 		},
 		{
 			URL: "http://localhost/admin/sso/providers/677477db-3f51-4038-bc05-c6bb9bdc3c32",
@@ -164,6 +170,7 @@ func (ts *SSOTestSuite) TestAdminCreateSSOProvider() {
 			StatusCode: http.StatusCreated,
 			Request: map[string]interface{}{
 				"type":         "saml",
+				"resource_id":  "abcdefgh",
 				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-B"),
 			},
 		},
@@ -195,6 +202,22 @@ func (ts *SSOTestSuite) TestAdminCreateSSOProvider() {
 				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-DUPLICATE"),
 			},
 		},
+		{
+			StatusCode: http.StatusCreated,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "duplicate",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-DUPLICATE-RID-A"),
+			},
+		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "duplicate",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-DUPLICATE-RID-B"),
+			},
+		},
 		{
 			StatusCode: http.StatusCreated,
 			Request: map[string]interface{}{
@@ -215,6 +238,14 @@ func (ts *SSOTestSuite) TestAdminCreateSSOProvider() {
 				},
 			},
 		},
+		{
+			StatusCode: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "1",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-WITH-INVALID-RESOURCE-ID"),
+			},
+		},
 		{
 			StatusCode: http.StatusBadRequest,
 			Request: map[string]interface{}{
@@ -333,6 +364,13 @@ func (ts *SSOTestSuite) TestAdminUpdateSSOProvider() {
 				},
 			},
 		},
+		{
+			Request: map[string]interface{}{
+				"type":         "saml",
+				"resource_id":  "example-d",
+				"metadata_xml": validSAMLIDPMetadata("https://accounts.google.com/o/saml2?idpid=EXAMPLE-D"),
+			},
+		},
 	}
 
 	for i, example := range providers {
@@ -401,6 +439,20 @@ func (ts *SSOTestSuite) TestAdminUpdateSSOProvider() {
 				},
 			},
 		},
+		{
+			ID:     providers[0].ID,
+			Status: http.StatusOK,
+			Request: map[string]interface{}{
+				"resource_id": "deadbeef",
+			},
+		},
+		{
+			ID:     providers[2].ID,
+			Status: http.StatusBadRequest,
+			Request: map[string]interface{}{
+				"resource_id": "deadbeef",
+			},
+		},
 	}
 
 	for _, example := range examples {
diff --git a/models/identity_test.go b/models/identity_test.go
index b28e1dfb..37dfe96f 100644
--- a/models/identity_test.go
+++ b/models/identity_test.go
@@ -53,7 +53,7 @@ func (ts *IdentityTestSuite) TestNewIdentity() {
 
 func (ts *IdentityTestSuite) TestFindUserIdentities() {
 	u := ts.createUserWithIdentity("test@supabase.io")
-	identities, err := FindIdentitiesByUserID(ts.db, u.ID)
+	identities, err := FindIdentitiesByUser(ts.db, u)
 	require.NoError(ts.T(), err)
 
 	require.Len(ts.T(), identities, 1)
diff --git a/models/linking_test.go b/models/linking_test.go
deleted file mode 100644
index a24d4ece..00000000
--- a/models/linking_test.go
+++ /dev/null
@@ -1,180 +0,0 @@
-package models
-
-import (
-	"testing"
-
-	"github.com/netlify/gotrue/conf"
-	"github.com/netlify/gotrue/storage"
-	"github.com/netlify/gotrue/storage/test"
-	"github.com/stretchr/testify/require"
-	"github.com/stretchr/testify/suite"
-)
-
-type AccountLinkingTestSuite struct {
-	suite.Suite
-
-	db *storage.Connection
-}
-
-func (ts *AccountLinkingTestSuite) SetupTest() {
-	TruncateAll(ts.db)
-}
-
-func TestAccountLinking(t *testing.T) {
-	globalConfig, err := conf.LoadGlobal(modelsTestConfig)
-	require.NoError(t, err)
-
-	conn, err := test.SetupDBConnection(globalConfig)
-	require.NoError(t, err)
-
-	ts := &AccountLinkingTestSuite{
-		db: conn,
-	}
-	defer ts.db.Close()
-
-	suite.Run(t, ts)
-}
-
-func (ts *AccountLinkingTestSuite) TestCreateAccountDecisionNoAccounts() {
-	// when there are no accounts in the system -- conventional provider
-	decision, err := DetermineAccountLinking(ts.db, "provider", "abcdefgh", []string{"test@example.com"})
-	require.NoError(ts.T(), err)
-
-	require.Equal(ts.T(), decision.Decision, CreateAccount)
-
-	// when there are no accounts in the system -- SSO provider
-	decision, err = DetermineAccountLinking(ts.db, "sso:f06f9e3d-ff92-4c47-a179-7acf1fda6387", "abcdefgh", []string{"test@example.com"})
-	require.NoError(ts.T(), err)
-
-	require.Equal(ts.T(), decision.Decision, CreateAccount)
-}
-
-func (ts *AccountLinkingTestSuite) TestCreateAccountDecisionWithAccounts() {
-	userA, err := NewUser("", "test@example.com", "", "authenticated", nil)
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(userA))
-	identityA, err := NewIdentity(userA, "provider", map[string]interface{}{
-		"sub":   userA.ID.String(),
-		"email": "test@example.com",
-	})
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(identityA))
-
-	userB, err := NewUser("", "test@samltest.id", "", "authenticated", nil)
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(userB))
-
-	identityB, err := NewIdentity(userB, "sso:f06f9e3d-ff92-4c47-a179-7acf1fda6387", map[string]interface{}{
-		"sub":   userB.ID.String(),
-		"email": "test@samltest.id",
-	})
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(identityB))
-
-	// when there are no accounts in the system -- conventional provider
-	decision, err := DetermineAccountLinking(ts.db, "provider", "abcdefgh", []string{"other@example.com"})
-	require.NoError(ts.T(), err)
-
-	require.Equal(ts.T(), decision.Decision, CreateAccount)
-
-	// when looking for an email that doesn't exist in the SSO linking domain
-	decision, err = DetermineAccountLinking(ts.db, "sso:f06f9e3d-ff92-4c47-a179-7acf1fda6387", "abcdefgh", []string{"other@samltest.id"})
-	require.NoError(ts.T(), err)
-
-	require.Equal(ts.T(), decision.Decision, CreateAccount)
-
-	// when looking for an email that doesn't exist at all
-	decision, err = DetermineAccountLinking(ts.db, "sso:f06f9e3d-ff92-4c47-a179-7acf1fda6387", "abcdefgh", []string{"other@samltest.id"})
-	require.NoError(ts.T(), err)
-
-	require.Equal(ts.T(), decision.Decision, CreateAccount)
-
-	// when looking for an email that doesn't exist in the SSO linking domain
-	decision, err = DetermineAccountLinking(ts.db, "sso:f06f9e3d-ff92-4c47-a179-7acf1fda6387", "abcdefgh", []string{"text@example.com"})
-	require.NoError(ts.T(), err)
-
-	require.Equal(ts.T(), decision.Decision, CreateAccount)
-}
-
-func (ts *AccountLinkingTestSuite) TestAccountExists() {
-	userA, err := NewUser("", "test@example.com", "", "authenticated", nil)
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(userA))
-	identityA, err := NewIdentity(userA, "provider", map[string]interface{}{
-		"sub":   userA.ID.String(),
-		"email": "test@example.com",
-	})
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(identityA))
-
-	decision, err := DetermineAccountLinking(ts.db, "provider", userA.ID.String(), []string{"test@example.com"})
-	require.NoError(ts.T(), err)
-
-	require.Equal(ts.T(), decision.Decision, AccountExists)
-	require.Equal(ts.T(), decision.User.ID, userA.ID)
-}
-
-func (ts *AccountLinkingTestSuite) TestLinkAccountExists() {
-	userA, err := NewUser("", "test@example.com", "", "authenticated", nil)
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(userA))
-	identityA, err := NewIdentity(userA, "provider", map[string]interface{}{
-		"sub":   userA.ID.String(),
-		"email": "test@example.com",
-	})
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(identityA))
-
-	// link decision because the below described identity is in the default linking domain but uses "other-provider" instead of "provder"
-	decision, err := DetermineAccountLinking(ts.db, "other-provider", userA.ID.String(), []string{"test@example.com"})
-	require.NoError(ts.T(), err)
-
-	require.Equal(ts.T(), decision.Decision, LinkAccount)
-
-	userB, err := NewUser("", "test@samltest.id", "", "authenticated", nil)
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(userB))
-
-	identityB, err := NewIdentity(userB, "sso:f06f9e3d-ff92-4c47-a179-7acf1fda6387", map[string]interface{}{
-		"sub":   userB.ID.String(),
-		"email": "test@samltest.id",
-	})
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(identityB))
-
-	// no link decision because the SSO linking domain is scoped to the provider unique ID
-	decision, err = DetermineAccountLinking(ts.db, "sso:f06f9e3d-ff92-4c47-a179-7acf1fda6387", userB.ID.String(), []string{"test@samltest.id"})
-	require.NoError(ts.T(), err)
-
-	require.NotEqual(ts.T(), decision.Decision, LinkAccount)
-}
-
-func (ts *AccountLinkingTestSuite) TestMultipleAccounts() {
-	userA, err := NewUser("", "test@example.com", "", "authenticated", nil)
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(userA))
-	identityA, err := NewIdentity(userA, "provider", map[string]interface{}{
-		"sub":   userA.ID.String(),
-		"email": "test@example.com",
-	})
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(identityA))
-
-	userB, err := NewUser("", "test-b@example.com", "", "authenticated", nil)
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(userB))
-	identityB, err := NewIdentity(userB, "provider", map[string]interface{}{
-		"sub":   userB.ID.String(),
-		"email": "test@example.com", // intentionally same as userA
-	})
-	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(identityB))
-
-	// decision is multiple accounts because there are two distinct
-	// identities in the same "default" linking domain with the same email
-	// address pointing to two different user accounts
-	decision, err := DetermineAccountLinking(ts.db, "provider", "abcdefgh", []string{"test@example.com"})
-	require.NoError(ts.T(), err)
-
-	require.Equal(ts.T(), decision.Decision, MultipleAccounts)
-}
diff --git a/models/sessions_test.go b/models/sessions_test.go
index b0991514..7e4eb647 100644
--- a/models/sessions_test.go
+++ b/models/sessions_test.go
@@ -57,8 +57,7 @@ func (ts *SessionsTestSuite) TestCalculateAALAndAMR() {
 	session, err = FindSessionByID(ts.db, session.ID)
 	require.NoError(ts.T(), err)
 
-	aal, amr, err := session.CalculateAALAndAMR(ts.db)
-	require.NoError(ts.T(), err)
+	aal, amr := session.CalculateAALAndAMR()
 	require.Equal(ts.T(), AAL2.String(), aal)
 	require.Equal(ts.T(), totalDistinctClaims, len(amr))
 
@@ -68,8 +67,7 @@ func (ts *SessionsTestSuite) TestCalculateAALAndAMR() {
 	session, err = FindSessionByID(ts.db, session.ID)
 	require.NoError(ts.T(), err)
 
-	aal, amr, err = session.CalculateAALAndAMR(ts.db)
-	require.NoError(ts.T(), err)
+	aal, amr = session.CalculateAALAndAMR()
 
 	require.Equal(ts.T(), AAL2.String(), aal)
 	require.Equal(ts.T(), totalDistinctClaims, len(amr))
diff --git a/models/sso_test.go b/models/sso_test.go
index 16943bfc..d51ee7dd 100644
--- a/models/sso_test.go
+++ b/models/sso_test.go
@@ -36,6 +36,10 @@ func TestSSO(t *tst.T) {
 }
 
 func (ts *SSOTestSuite) TestConstraints() {
+	resourceID := func(rid string) *string {
+		return &rid
+	}
+
 	type exampleSpec struct {
 		Provider *SSOProvider
 	}
@@ -70,6 +74,11 @@ func (ts *SSOTestSuite) TestConstraints() {
 				},
 			},
 		},
+		{
+			Provider: &SSOProvider{
+				ResourceID: resourceID(""),
+			},
+		},
 	}
 
 	for i, example := range examples {
diff --git a/models/user_test.go b/models/user_test.go
index 66369d28..5935e922 100644
--- a/models/user_test.go
+++ b/models/user_test.go
@@ -164,19 +164,19 @@ func (ts *UserTestSuite) TestIsDuplicatedEmail() {
 
 	e, err := IsDuplicatedEmail(ts.db, "david.calavera@netlify.com", "test")
 	require.NoError(ts.T(), err)
-	require.NotNil(ts.T(), e, "expected email to be duplicated")
+	require.True(ts.T(), e, "expected email to be duplicated")
 
 	e, err = IsDuplicatedEmail(ts.db, "davidcalavera@netlify.com", "test")
 	require.NoError(ts.T(), err)
-	require.Nil(ts.T(), e, "expected email to not be duplicated")
+	require.False(ts.T(), e, "expected email to not be duplicated")
 
 	e, err = IsDuplicatedEmail(ts.db, "david@netlify.com", "test")
 	require.NoError(ts.T(), err)
-	require.Nil(ts.T(), e, "expected same email to not be duplicated")
+	require.False(ts.T(), e, "expected same email to not be duplicated")
 
 	e, err = IsDuplicatedEmail(ts.db, "david.calavera@netlify.com", "other-aud")
 	require.NoError(ts.T(), err)
-	require.Nil(ts.T(), e, "expected same email to not be duplicated")
+	require.False(ts.T(), e, "expected same email to not be duplicated")
 }
 
 func (ts *UserTestSuite) createUser() *User {
@@ -186,14 +186,9 @@ func (ts *UserTestSuite) createUser() *User {
 func (ts *UserTestSuite) createUserWithEmail(email string) *User {
 	user, err := NewUser("", email, "secret", "test", nil)
 	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(user))
 
-	identity, err := NewIdentity(user, "email", map[string]interface{}{
-		"sub":   user.ID.String(),
-		"email": email,
-	})
+	err = ts.db.Create(user)
 	require.NoError(ts.T(), err)
-	require.NoError(ts.T(), ts.db.Create(identity))
 
 	return user
 }