Repository: supabase/gotrue
Pull request #408 (2022-03-04 11:23:45)

fix: update user email should not fail when current email doesn't exist
## What kind of change does this PR introduce?
* Fixes issue where updating a user's email fails if the existing email is empty (when `SECURE_EMAIL_CHANGE_ENABLED=true`)
* This issue happens because `SECURE_EMAIL_CHANGE_ENABLED` attempts to send an email confirmation link to both the current and new email, since the current email doesn't exist, the mail client fails to send the link.
* Refactored the `TemplateMailer` to use an interface for the `Mailer`. This makes mocking the `mailme.Mailer.Mail` method easier rather than just mocking the `EmailChangeMail` method.
* Added tests for updating a user 


Patch:
diff --git a/README.md b/README.md
index 63f5c25b..31fc6bb5 100644
--- a/README.md
+++ b/README.md
@@ -104,6 +104,10 @@ Chooses what dialect of database you want. Must be `mysql`.
 
 Connection string for the database.
 
+`GOTRUE_DB_MAX_POOL_SIZE` - `int`
+
+Sets the maximum number of open connections to the database. Defaults to 0 which is equivalent to an "unlimited" number of connections.
+
 `DB_NAMESPACE` - `string`
 
 Adds a prefix to all table names.
@@ -193,7 +197,7 @@ The default group to assign all new users to.
 
 ### External Authentication Providers
 
-We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `linkedin`, `notion`, `spotify`, `slack`, `twitch` and `twitter` for external authentication.
+We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `github`, `gitlab`, `google`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication.
 
 Use the names as the keys underneath `external` to configure each separately.
 
@@ -514,7 +518,8 @@ Returns the publicly available settings for this gotrue instance.
     "slack": true,
     "spotify": true,
     "twitch": true,
-    "twitter": true
+    "twitter": true,
+    "workos": true,
   },
   "disable_signup": false,
   "autoconfirm": false
@@ -922,7 +927,7 @@ Get access_token from external oauth provider
 query params:
 
 ```
-provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | linkedin | notion | slack | spotify | twitch | twitter
+provider=apple | azure | bitbucket | discord | facebook | github | gitlab | google | linkedin | notion | slack | spotify | twitch | twitter | workos
 scopes=<optional additional scopes depending on the provider (email and name are requested by default)>
 ```
 
diff --git a/api/external.go b/api/external.go
index 4d4a96c3..9df6b6e3 100644
--- a/api/external.go
+++ b/api/external.go
@@ -38,15 +38,16 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
 	ctx := r.Context()
 	config := a.getConfig(ctx)
 
-	providerType := r.URL.Query().Get("provider")
-	scopes := r.URL.Query().Get("scopes")
+	query := r.URL.Query()
+	providerType := query.Get("provider")
+	scopes := query.Get("scopes")
 
-	p, err := a.Provider(ctx, providerType, scopes)
+	p, err := a.Provider(ctx, providerType, scopes, &query)
 	if err != nil {
 		return badRequestError("Unsupported provider: %+v", err).WithInternalError(err)
 	}
 
-	inviteToken := r.URL.Query().Get("invite_token")
+	inviteToken := query.Get("invite_token")
 	if inviteToken != "" {
 		_, userErr := models.FindUserByConfirmationToken(a.db, inviteToken)
 		if userErr != nil {
@@ -57,7 +58,7 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
 		}
 	}
 
-	redirectURL := a.getRedirectURLOrReferrer(r, r.URL.Query().Get("redirect_to"))
+	redirectURL := a.getRedirectURLOrReferrer(r, query.Get("redirect_to"))
 	log := getLogEntry(r)
 	log.WithField("provider", providerType).Info("Redirecting to external provider")
 
@@ -383,7 +384,7 @@ func (a *API) loadExternalState(ctx context.Context, state string) (context.Cont
 }
 
 // Provider returns a Provider interface for the given name.
-func (a *API) Provider(ctx context.Context, name string, scopes string) (provider.Provider, error) {
+func (a *API) Provider(ctx context.Context, name string, scopes string, query *url.Values) (provider.Provider, error) {
 	config := a.getConfig(ctx)
 	name = strings.ToLower(name)
 
@@ -416,6 +417,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
 		return provider.NewTwitchProvider(config.External.Twitch, scopes)
 	case "twitter":
 		return provider.NewTwitterProvider(config.External.Twitter, scopes)
+	case "workos":
+		return provider.NewWorkOSProvider(config.External.WorkOS, query)
 	case "saml":
 		return provider.NewSamlProvider(config.External.Saml, a.db, getInstanceID(ctx))
 	case "zoom":
diff --git a/api/external_oauth.go b/api/external_oauth.go
index 3a4899ef..0e7e72a4 100644
--- a/api/external_oauth.go
+++ b/api/external_oauth.go
@@ -141,7 +141,7 @@ func (a *API) oAuth1Callback(ctx context.Context, r *http.Request, providerType
 
 // OAuthProvider returns the corresponding oauth provider as an OAuthProvider interface
 func (a *API) OAuthProvider(ctx context.Context, name string) (provider.OAuthProvider, error) {
-	providerCandidate, err := a.Provider(ctx, name, "")
+	providerCandidate, err := a.Provider(ctx, name, "", nil)
 	if err != nil {
 		return nil, err
 	}
diff --git a/api/mail.go b/api/mail.go
index edf8b0f6..5d9ea140 100644
--- a/api/mail.go
+++ b/api/mail.go
@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"time"
 
+	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/crypto"
 	"github.com/netlify/gotrue/mailer"
 	"github.com/netlify/gotrue/models"
@@ -229,30 +230,12 @@ func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer maile
 	return errors.Wrap(tx.UpdateOnly(u, "recovery_token", "recovery_sent_at"), "Database error updating user for recovery")
 }
 
-// sendSecureEmailChange sends out an email change token each to the old and new emails.
-func (a *API) sendSecureEmailChange(tx *storage.Connection, u *models.User, mailer mailer.Mailer, email string, referrerURL string) error {
-	u.EmailChangeTokenCurrent, u.EmailChangeTokenNew = crypto.SecureToken(), crypto.SecureToken()
-	u.EmailChange = email
-	u.EmailChangeConfirmStatus = zeroConfirmation
-	now := time.Now()
-	if err := mailer.EmailChangeMail(u, referrerURL); err != nil {
-		return err
-	}
-
-	u.EmailChangeSentAt = &now
-	return errors.Wrap(tx.UpdateOnly(
-		u,
-		"email_change_token_current",
-		"email_change_token_new",
-		"email_change",
-		"email_change_sent_at",
-		"email_change_confirm_status",
-	), "Database error updating user for email change")
-}
-
 // sendEmailChange sends out an email change token to the new email.
-func (a *API) sendEmailChange(tx *storage.Connection, u *models.User, mailer mailer.Mailer, email string, referrerURL string) error {
+func (a *API) sendEmailChange(tx *storage.Connection, config *conf.Configuration, u *models.User, mailer mailer.Mailer, email string, referrerURL string) error {
 	u.EmailChangeTokenNew = crypto.SecureToken()
+	if config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" {
+		u.EmailChangeTokenCurrent = crypto.SecureToken()
+	}
 	u.EmailChange = email
 	u.EmailChangeConfirmStatus = zeroConfirmation
 	now := time.Now()
@@ -263,6 +246,7 @@ func (a *API) sendEmailChange(tx *storage.Connection, u *models.User, mailer mai
 	u.EmailChangeSentAt = &now
 	return errors.Wrap(tx.UpdateOnly(
 		u,
+		"email_change_token_current",
 		"email_change_token_new",
 		"email_change",
 		"email_change_sent_at",
diff --git a/api/provider/workos.go b/api/provider/workos.go
new file mode 100644
index 00000000..91ff8dd3
--- /dev/null
+++ b/api/provider/workos.go
@@ -0,0 +1,126 @@
+package provider
+
+import (
+	"context"
+	"errors"
+	"net/url"
+	"strings"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/netlify/gotrue/conf"
+	"golang.org/x/oauth2"
+)
+
+const (
+	defaultWorkOSAPIBase = "api.workos.com"
+)
+
+type workosProvider struct {
+	*oauth2.Config
+	APIPath         string
+	AuthCodeOptions []oauth2.AuthCodeOption
+}
+
+// See https://workos.com/docs/reference/sso/profile.
+type workosUser struct {
+	ID             string                 `mapstructure:"id"`
+	ConnectionID   string                 `mapstructure:"connection_id"`
+	OrganizationID string                 `mapstructure:"organization_id"`
+	ConnectionType string                 `mapstructure:"connection_type"`
+	Email          string                 `mapstructure:"email"`
+	FirstName      string                 `mapstructure:"first_name"`
+	LastName       string                 `mapstructure:"last_name"`
+	Object         string                 `mapstructure:"object"`
+	IdpID          string                 `mapstructure:"idp_id"`
+	RawAttributes  map[string]interface{} `mapstructure:"raw_attributes"`
+}
+
+// NewWorkOSProvider creates a WorkOS account provider.
+func NewWorkOSProvider(ext conf.OAuthProviderConfiguration, query *url.Values) (OAuthProvider, error) {
+	if err := ext.Validate(); err != nil {
+		return nil, err
+	}
+	apiPath := chooseHost(ext.URL, defaultWorkOSAPIBase)
+
+	// Attach custom query parameters to the WorkOS authorization URL.
+	// See https://workos.com/docs/reference/sso/authorize/get.
+	var authCodeOptions []oauth2.AuthCodeOption
+	if query != nil {
+		if connection := query.Get("connection"); connection != "" {
+			authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("connection", connection))
+		} else if organization := query.Get("organization"); organization != "" {
+			authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("organization", organization))
+		} else if provider := query.Get("workos_provider"); provider != "" {
+			authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("provider", provider))
+		}
+
+		if login_hint := query.Get("login_hint"); login_hint != "" {
+			authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("login_hint", login_hint))
+		}
+	}
+
+	return &workosProvider{
+		Config: &oauth2.Config{
+			ClientID:     ext.ClientID,
+			ClientSecret: ext.Secret,
+			Endpoint: oauth2.Endpoint{
+				AuthURL:  apiPath + "/sso/authorize",
+				TokenURL: apiPath + "/sso/token",
+			},
+			RedirectURL: ext.RedirectURI,
+		},
+		APIPath:         apiPath,
+		AuthCodeOptions: authCodeOptions,
+	}, nil
+}
+
+func (g workosProvider) AuthCodeURL(state string, args ...oauth2.AuthCodeOption) string {
+	opts := append(args, g.AuthCodeOptions...)
+	return g.Config.AuthCodeURL(state, opts...)
+}
+
+func (g workosProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
+	return g.Exchange(oauth2.NoContext, code)
+}
+
+func (g workosProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
+	if tok.AccessToken == "" {
+		return &UserProvidedData{}, nil
+	}
+
+	// WorkOS API returns the user's profile data along with the OAuth2 token, so
+	// we can just convert from `map[string]interface{}` to `workosUser` without
+	// an additional network request.
+	var u workosUser
+	err := mapstructure.Decode(tok.Extra("profile"), &u)
+	if err != nil {
+		return nil, err
+	}
+
+	if u.Email == "" {
+		return nil, errors.New("Unable to find email with WorkOS provider")
+	}
+
+	return &UserProvidedData{
+		Metadata: &Claims{
+			Issuer:        g.APIPath,
+			Subject:       u.ID,
+			Name:          strings.TrimSpace(u.FirstName + " " + u.LastName),
+			Email:         u.Email,
+			EmailVerified: true,
+			CustomClaims: map[string]interface{}{
+				"connection_id":   u.ConnectionID,
+				"organization_id": u.OrganizationID,
+			},
+
+			// To be deprecated
+			FullName:   strings.TrimSpace(u.FirstName + " " + u.LastName),
+			ProviderId: u.ID,
+		},
+		Emails: []Email{{
+			Email:    u.Email,
+			Verified: true,
+			Primary:  true,
+		}},
+	}, nil
+}
diff --git a/api/settings.go b/api/settings.go
index 56e760ef..929593ed 100644
--- a/api/settings.go
+++ b/api/settings.go
@@ -15,6 +15,7 @@ type ProviderSettings struct {
 	Notion    bool `json:"notion"`
 	Spotify   bool `json:"spotify"`
 	Slack     bool `json:"slack"`
+	WorkOS    bool `json:"workos"`
 	Twitch    bool `json:"twitch"`
 	Twitter   bool `json:"twitter"`
 	Email     bool `json:"email"`
@@ -55,6 +56,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
 			Slack:     config.External.Slack.Enabled,
 			Twitch:    config.External.Twitch.Enabled,
 			Twitter:   config.External.Twitter.Enabled,
+			WorkOS:    config.External.WorkOS.Enabled,
 			Email:     config.External.Email.Enabled,
 			Phone:     config.External.Phone.Enabled,
 			SAML:      config.External.Saml.Enabled,
diff --git a/api/user.go b/api/user.go
index 4ef51525..56296378 100644
--- a/api/user.go
+++ b/api/user.go
@@ -119,14 +119,8 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 
 			mailer := a.Mailer(ctx)
 			referrer := a.getReferrer(r)
-			if config.Mailer.SecureEmailChangeEnabled {
-				if terr = a.sendSecureEmailChange(tx, user, mailer, params.Email, referrer); terr != nil {
-					return internalServerError("Error sending change email").WithInternalError(terr)
-				}
-			} else {
-				if terr = a.sendEmailChange(tx, user, mailer, params.Email, referrer); terr != nil {
-					return internalServerError("Error sending change email").WithInternalError(terr)
-				}
+			if terr = a.sendEmailChange(tx, config, user, mailer, params.Email, referrer); terr != nil {
+				return internalServerError("Error sending change email").WithInternalError(terr)
 			}
 		}
 
diff --git a/api/verify.go b/api/verify.go
index c6fa4cc4..06bfe05a 100644
--- a/api/verify.go
+++ b/api/verify.go
@@ -280,7 +280,7 @@ func (a *API) emailChangeVerify(ctx context.Context, conn *storage.Connection, p
 	instanceID := getInstanceID(ctx)
 	config := a.getConfig(ctx)
 
-	if config.Mailer.SecureEmailChangeEnabled && user.EmailChangeConfirmStatus == zeroConfirmation {
+	if config.Mailer.SecureEmailChangeEnabled && user.EmailChangeConfirmStatus == zeroConfirmation && user.GetEmail() != "" {
 		err := a.db.Transaction(func(tx *storage.Connection) error {
 			user.EmailChangeConfirmStatus = singleConfirmation
 			if params.Token == user.EmailChangeTokenCurrent {
diff --git a/conf/configuration.go b/conf/configuration.go
index f030ac37..dbea9beb 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -38,8 +38,11 @@ type SamlProviderConfiguration struct {
 
 // DBConfiguration holds all the database related configuration.
 type DBConfiguration struct {
-	Driver         string `json:"driver" required:"true"`
-	URL            string `json:"url" envconfig:"DATABASE_URL" required:"true"`
+	Driver string `json:"driver" required:"true"`
+	URL    string `json:"url" envconfig:"DATABASE_URL" required:"true"`
+
+	// MaxPoolSize defaults to 0 (unlimited).
+	MaxPoolSize    int    `json:"max_pool_size" split_words:"true"`
 	MigrationsPath string `json:"migrations_path" split_words:"true" default:"./migrations"`
 }
 
@@ -97,6 +100,7 @@ type ProviderConfiguration struct {
 	Slack       OAuthProviderConfiguration `json:"slack"`
 	Twitter     OAuthProviderConfiguration `json:"twitter"`
 	Twitch      OAuthProviderConfiguration `json:"twitch"`
+	WorkOS      OAuthProviderConfiguration `json:"workos"`
 	Email       EmailProviderConfiguration `json:"email"`
 	Phone       PhoneProviderConfiguration `json:"phone"`
 	Saml        SamlProviderConfiguration  `json:"saml"`
diff --git a/example.env b/example.env
index 44cc6899..0782f6ec 100644
--- a/example.env
+++ b/example.env
@@ -135,13 +135,19 @@ GOTRUE_EXTERNAL_LINKEDIN_SECRET=""
 GOTRUE_EXTERNAL_SLACK_ENABLED="false"
 GOTRUE_EXTERNAL_SLACK_CLIENT_ID=""
 GOTRUE_EXTERNAL_SLACK_SECRET=""
-GOTRUE_EXTERNAL_SLACK_REDIRECT_URI="https://localhost:9999/callback"
+GOTRUE_EXTERNAL_SLACK_REDIRECT_URI="http://localhost:9999/callback"
+
+# WorkOS OAuth config
+GOTRUE_EXTERNAL_WORKOS_ENABLED="true"
+GOTRUE_EXTERNAL_WORKOS_CLIENT_ID=""
+GOTRUE_EXTERNAL_WORKOS_SECRET=""
+GOTRUE_EXTERNAL_WORKOS_REDIRECT_URI="http://localhost:9999/callback"
 
 # Zoom OAuth config
 GOTRUE_EXTERNAL_ZOOM_ENABLED="false"
 GOTRUE_EXTERNAL_ZOOM_CLIENT_ID=""
 GOTRUE_EXTERNAL_ZOOM_SECRET=""
-GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="https://localhost:9999/callback"
+GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback"
 
 # Phone provider config
 GOTRUE_SMS_AUTOCONFIRM="false"
@@ -191,4 +197,4 @@ GOTRUE_WEBHOOK_EVENTS=validate,signup,login
 
 # Cookie config 
 GOTRUE_COOKIE_KEY: "sb"
-GOTRUE_COOKIE_DOMAIN: "localhost"
\ No newline at end of file
+GOTRUE_COOKIE_DOMAIN: "localhost"
diff --git a/go.mod b/go.mod
index 1563512c..e8130727 100644
--- a/go.mod
+++ b/go.mod
@@ -31,6 +31,7 @@ require (
 	github.com/lestrrat-go/jwx v0.9.0
 	github.com/lib/pq v1.9.0 // indirect
 	github.com/microcosm-cc/bluemonday v1.0.16 // indirect
+	github.com/mitchellh/mapstructure v1.1.2
 	github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450
 	github.com/netlify/mailme v1.1.1
 	github.com/opentracing/opentracing-go v1.1.0
diff --git a/go.sum b/go.sum
index 6ca10922..2fd375e2 100644
--- a/go.sum
+++ b/go.sum
@@ -420,6 +420,7 @@ github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eI
 github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
 github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
diff --git a/mailer/mailer.go b/mailer/mailer.go
index 49657276..28fbea68 100644
--- a/mailer/mailer.go
+++ b/mailer/mailer.go
@@ -25,18 +25,15 @@ type Mailer interface {
 
 // NewMailer returns a new gotrue mailer
 func NewMailer(instanceConfig *conf.Configuration) Mailer {
-	if instanceConfig.SMTP.Host == "" {
-		logrus.Infof("Noop mailer being used for %v", instanceConfig.SiteURL)
-		return &noopMailer{}
-	}
-
 	mail := gomail.NewMessage()
 	from := mail.FormatAddress(instanceConfig.SMTP.AdminEmail, instanceConfig.SMTP.SenderName)
 
-	return &TemplateMailer{
-		SiteURL: instanceConfig.SiteURL,
-		Config:  instanceConfig,
-		Mailer: &mailme.Mailer{
+	var mailClient MailClient
+	if instanceConfig.SMTP.Host == "" {
+		logrus.Infof("Noop mail client being used for %v", instanceConfig.SiteURL)
+		mailClient = &noopMailClient{}
+	} else {
+		mailClient = &mailme.Mailer{
 			Host:    instanceConfig.SMTP.Host,
 			Port:    instanceConfig.SMTP.Port,
 			User:    instanceConfig.SMTP.User,
@@ -44,7 +41,13 @@ func NewMailer(instanceConfig *conf.Configuration) Mailer {
 			From:    from,
 			BaseURL: instanceConfig.SiteURL,
 			Logger:  logrus.New(),
-		},
+		}
+	}
+
+	return &TemplateMailer{
+		SiteURL: instanceConfig.SiteURL,
+		Config:  instanceConfig,
+		Mailer:  mailClient,
 	}
 }
 
diff --git a/mailer/noop.go b/mailer/noop.go
index 9c486fc4..b35a7af1 100644
--- a/mailer/noop.go
+++ b/mailer/noop.go
@@ -1,8 +1,13 @@
 package mailer
 
-import "github.com/netlify/gotrue/models"
+import (
+	"errors"
+
+	"github.com/netlify/gotrue/models"
+)
 
 type noopMailer struct {
+	Mailer MailClient
 }
 
 func (m noopMailer) ValidateEmail(email string) error {
@@ -36,3 +41,12 @@ func (m noopMailer) Send(user *models.User, subject, body string, data map[strin
 func (m noopMailer) GetEmailActionLink(user *models.User, actionType, referrerURL string) (string, error) {
 	return "", nil
 }
+
+type noopMailClient struct{}
+
+func (m *noopMailClient) Mail(to, subjectTemplate, templateURL, defaultTemplate string, templateData map[string]interface{}) error {
+	if to == "" {
+		return errors.New("to field cannot be empty")
+	}
+	return nil
+}
diff --git a/mailer/template.go b/mailer/template.go
index 634eb4f8..bda1ab5b 100644
--- a/mailer/template.go
+++ b/mailer/template.go
@@ -6,14 +6,17 @@ import (
 	"github.com/badoux/checkmail"
 	"github.com/netlify/gotrue/conf"
 	"github.com/netlify/gotrue/models"
-	"github.com/netlify/mailme"
 )
 
+type MailClient interface {
+	Mail(string, string, string, string, map[string]interface{}) error
+}
+
 // TemplateMailer will send mail and use templates from the site for easy mail styling
 type TemplateMailer struct {
 	SiteURL string
 	Config  *conf.Configuration
-	Mailer  *mailme.Mailer
+	Mailer  MailClient
 }
 
 var configFile = ""
@@ -132,9 +135,10 @@ func (m *TemplateMailer) EmailChangeMail(user *models.User, referrerURL string)
 		},
 	}
 
-	if m.Config.Mailer.SecureEmailChangeEnabled {
+	currentEmail := user.GetEmail()
+	if m.Config.Mailer.SecureEmailChangeEnabled && currentEmail != "" {
 		emails = append(emails, Email{
-			Address:  user.GetEmail(),
+			Address:  currentEmail,
 			Token:    user.EmailChangeTokenCurrent,
 			Subject:  string(withDefault(m.Config.Mailer.Subjects.Confirmation, "Confirm Email Address")),
 			Template: m.Config.Mailer.Templates.EmailChange,
diff --git a/storage/dial.go b/storage/dial.go
index e176851e..57619819 100644
--- a/storage/dial.go
+++ b/storage/dial.go
@@ -31,6 +31,7 @@ func Dial(config *conf.GlobalConfiguration) (*Connection, error) {
 	db, err := pop.NewConnection(&pop.ConnectionDetails{
 		Dialect: config.DB.Driver,
 		URL:     config.DB.URL,
+		Pool:    config.DB.MaxPoolSize,
 	})
 	if err != nil {
 		return nil, errors.Wrap(err, "opening database connection")


Test patch:
diff --git a/api/external_azure_test.go b/api/external_azure_test.go
index b0599c45..4e8c874b 100644
--- a/api/external_azure_test.go
+++ b/api/external_azure_test.go
@@ -67,7 +67,6 @@ func AzureTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int
 
 func (ts *ExternalTestSuite) TestSignupExternalAzure_AuthorizationCode() {
 	ts.Config.DisableSignup = false
-	ts.createUser("azuretestid", "azure@example.com", "Azure Test", "", "")
 	tokenCount, userCount := 0, 0
 	code := "authcode"
 	server := AzureTestSignupSetup(ts, &tokenCount, &userCount, code, azureUser)
@@ -106,7 +105,7 @@ func (ts *ExternalTestSuite) TestSignupExternalAzureDisableSignupErrorWhenNoEmai
 func (ts *ExternalTestSuite) TestSignupExternalAzureDisableSignupSuccessWithPrimaryEmail() {
 	ts.Config.DisableSignup = true
 
-	ts.createUser("azuretestid", "azure@example.com", "Azure Test", "", "")
+	ts.createUser("azuretestid", "azure@example.com", "Azure Test", "http://example.com/avatar", "")
 
 	tokenCount, userCount := 0, 0
 	code := "authcode"
@@ -115,12 +114,12 @@ func (ts *ExternalTestSuite) TestSignupExternalAzureDisableSignupSuccessWithPrim
 
 	u := performAuthorization(ts, "azure", code, "")
 
-	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "azure@example.com", "Azure Test", "azuretestid", "")
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "azure@example.com", "Azure Test", "azuretestid", "http://example.com/avatar")
 }
 
 func (ts *ExternalTestSuite) TestInviteTokenExternalAzureSuccessWhenMatchingToken() {
-	// name and avatar should be populated from Azure API
-	ts.createUser("azuretestid", "azure@example.com", "", "", "invite_token")
+	// name should be populated from Azure API
+	ts.createUser("azuretestid", "azure@example.com", "", "http://example.com/avatar", "invite_token")
 
 	tokenCount, userCount := 0, 0
 	code := "authcode"
@@ -129,7 +128,7 @@ func (ts *ExternalTestSuite) TestInviteTokenExternalAzureSuccessWhenMatchingToke
 
 	u := performAuthorization(ts, "azure", code, "invite_token")
 
-	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "azure@example.com", "Azure Test", "azuretestid", "")
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "azure@example.com", "Azure Test", "azuretestid", "http://example.com/avatar")
 }
 
 func (ts *ExternalTestSuite) TestInviteTokenExternalAzureErrorWhenNoMatchingToken() {
diff --git a/api/external_test.go b/api/external_test.go
index 9aee2c30..d6260e24 100644
--- a/api/external_test.go
+++ b/api/external_test.go
@@ -120,7 +120,11 @@ func assertAuthorizationSuccess(ts *ExternalTestSuite, u *url.URL, tokenCount in
 	ts.Require().NoError(err)
 	ts.Equal(providerId, user.UserMetaData["provider_id"])
 	ts.Equal(name, user.UserMetaData["full_name"])
-	ts.Equal(avatar, user.UserMetaData["avatar_url"])
+	if avatar == "" {
+		ts.Equal(nil, user.UserMetaData["avatar_url"])
+	} else {
+		ts.Equal(avatar, user.UserMetaData["avatar_url"])
+	}
 }
 
 func assertAuthorizationFailure(ts *ExternalTestSuite, u *url.URL, errorDescription string, errorType string, email string) {
diff --git a/api/external_workos_test.go b/api/external_workos_test.go
new file mode 100644
index 00000000..b2e1e599
--- /dev/null
+++ b/api/external_workos_test.go
@@ -0,0 +1,221 @@
+package api
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+
+	jwt "github.com/golang-jwt/jwt"
+)
+
+const (
+	workosUser           string = `{"id":"test_prof_workos","first_name":"John","last_name":"Doe","email":"workos@example.com","connection_id":"test_conn_1","organization_id":"test_org_1","connection_type":"test","idp_id":"test_idp_1","object": "profile","raw_attributes": {}}`
+	workosUserWrongEmail string = `{"id":"test_prof_workos","first_name":"John","last_name":"Doe","email":"other@example.com","connection_id":"test_conn_1","organization_id":"test_org_1","connection_type":"test","idp_id":"test_idp_1","object": "profile","raw_attributes": {}}`
+	workosUserNoEmail    string = `{"id":"test_prof_workos","first_name":"John","last_name":"Doe","connection_id":"test_conn_1","organization_id":"test_org_1","connection_type":"test","idp_id":"test_idp_1","object": "profile","raw_attributes": {}}`
+)
+
+func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithConnection() {
+	connection := "test_connection_id"
+	req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/authorize?provider=workos&connection=%s", connection), nil)
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	ts.Require().Equal(http.StatusFound, w.Code)
+	u, err := url.Parse(w.Header().Get("Location"))
+	ts.Require().NoError(err, "redirect url parse failed")
+	q := u.Query()
+	ts.Equal(ts.Config.External.WorkOS.RedirectURI, q.Get("redirect_uri"))
+	ts.Equal(ts.Config.External.WorkOS.ClientID, q.Get("client_id"))
+	ts.Equal("code", q.Get("response_type"))
+	ts.Equal("", q.Get("scope"))
+	ts.Equal(connection, q.Get("connection"))
+
+	claims := ExternalProviderClaims{}
+	p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
+	_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
+		return []byte(ts.Config.JWT.Secret), nil
+	})
+	ts.Require().NoError(err)
+
+	ts.Equal("workos", claims.Provider)
+	ts.Equal(ts.Config.SiteURL, claims.SiteURL)
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithOrganization() {
+	organization := "test_organization_id"
+	req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/authorize?provider=workos&organization=%s", organization), nil)
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	ts.Require().Equal(http.StatusFound, w.Code)
+	u, err := url.Parse(w.Header().Get("Location"))
+	ts.Require().NoError(err, "redirect url parse failed")
+	q := u.Query()
+	ts.Equal(ts.Config.External.WorkOS.RedirectURI, q.Get("redirect_uri"))
+	ts.Equal(ts.Config.External.WorkOS.ClientID, q.Get("client_id"))
+	ts.Equal("code", q.Get("response_type"))
+	ts.Equal("", q.Get("scope"))
+	ts.Equal(organization, q.Get("organization"))
+
+	claims := ExternalProviderClaims{}
+	p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
+	_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
+		return []byte(ts.Config.JWT.Secret), nil
+	})
+	ts.Require().NoError(err)
+
+	ts.Equal("workos", claims.Provider)
+	ts.Equal(ts.Config.SiteURL, claims.SiteURL)
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalWorkOSWithProvider() {
+	provider := "test_provider"
+	req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost/authorize?provider=workos&workos_provider=%s", provider), nil)
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	ts.Require().Equal(http.StatusFound, w.Code)
+	u, err := url.Parse(w.Header().Get("Location"))
+	ts.Require().NoError(err, "redirect url parse failed")
+	q := u.Query()
+	ts.Equal(ts.Config.External.WorkOS.RedirectURI, q.Get("redirect_uri"))
+	ts.Equal(ts.Config.External.WorkOS.ClientID, q.Get("client_id"))
+	ts.Equal("code", q.Get("response_type"))
+	ts.Equal("", q.Get("scope"))
+	ts.Equal(provider, q.Get("provider"))
+
+	claims := ExternalProviderClaims{}
+	p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}}
+	_, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) {
+		return []byte(ts.Config.JWT.Secret), nil
+	})
+	ts.Require().NoError(err)
+
+	ts.Equal("workos", claims.Provider)
+	ts.Equal(ts.Config.SiteURL, claims.SiteURL)
+}
+
+func WorkosTestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/sso/token":
+			// WorkOS returns the user data along with the token.
+			*tokenCount++
+			*userCount++
+			ts.Equal(code, r.FormValue("code"))
+			ts.Equal("authorization_code", r.FormValue("grant_type"))
+			ts.Equal(ts.Config.External.WorkOS.RedirectURI, r.FormValue("redirect_uri"))
+
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprintf(w, `{"access_token":"workos_token","expires_in":100000,"profile":%s}`, user)
+		default:
+			fmt.Printf("%s", r.URL.Path)
+			w.WriteHeader(500)
+			ts.Fail("unknown workos oauth call %s", r.URL.Path)
+		}
+	}))
+
+	ts.Config.External.WorkOS.URL = server.URL
+
+	return server
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalWorkosAuthorizationCode() {
+	ts.Config.DisableSignup = false
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "workos", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "workos@example.com", "John Doe", "test_prof_workos", "")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalWorkosDisableSignupErrorWhenNoUser() {
+	ts.Config.DisableSignup = true
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "workos", code, "")
+
+	assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "workos@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalWorkosDisableSignupErrorWhenEmptyEmail() {
+	ts.Config.DisableSignup = true
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUserNoEmail)
+	defer server.Close()
+
+	u := performAuthorization(ts, "workos", code, "")
+
+	assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "workos@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalWorkosDisableSignupSuccessWithPrimaryEmail() {
+	ts.Config.DisableSignup = true
+
+	ts.createUser("test_prof_workos", "workos@example.com", "John Doe", "http://example.com/avatar", "")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "workos", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "workos@example.com", "John Doe", "test_prof_workos", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosSuccessWhenMatchingToken() {
+	ts.createUser("test_prof_workos", "workos@example.com", "", "http://example.com/avatar", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "workos", code, "invite_token")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "workos@example.com", "John Doe", "test_prof_workos", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosErrorWhenNoMatchingToken() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "workos", "invite_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosErrorWhenWrongToken() {
+	ts.createUser("test_prof_workos", "workos@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUser)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "workos", "wrong_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalWorkosErrorWhenEmailDoesntMatch() {
+	ts.createUser("test_prof_workos", "workos@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := WorkosTestSignupSetup(ts, &tokenCount, &userCount, code, workosUserWrongEmail)
+	defer server.Close()
+
+	u := performAuthorization(ts, "workos", code, "invite_token")
+
+	assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
+}
diff --git a/api/settings_test.go b/api/settings_test.go
index 922b5ff0..4e02e5bf 100644
--- a/api/settings_test.go
+++ b/api/settings_test.go
@@ -41,6 +41,7 @@ func TestSettings_DefaultProviders(t *testing.T) {
 	require.True(t, p.GitLab)
 	require.True(t, p.SAML)
 	require.True(t, p.Twitch)
+	require.True(t, p.WorkOS)
 	require.True(t, p.Zoom)
 }
 
diff --git a/api/user_test.go b/api/user_test.go
index baae1f35..8d8566b1 100644
--- a/api/user_test.go
+++ b/api/user_test.go
@@ -47,7 +47,94 @@ func (ts *UserTestSuite) SetupTest() {
 	require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
 }
 
-func (ts *UserTestSuite) TestUser_UpdatePassword() {
+func (ts *UserTestSuite) TestUserGet() {
+	u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err, "Error finding user")
+	token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	require.NoError(ts.T(), err, "Error generating access token")
+
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/user", nil)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), http.StatusOK, w.Code)
+}
+
+func (ts *UserTestSuite) TestUserUpdateEmail() {
+	cases := []struct {
+		desc                       string
+		userData                   map[string]string
+		isSecureEmailChangeEnabled bool
+		expectedCode               int
+	}{
+		{
+			"User doesn't have an existing email",
+			map[string]string{
+				"email": "",
+				"phone": "123456789",
+			},
+			false,
+			http.StatusOK,
+		},
+		{
+			"User doesn't have an existing email and double email confirmation required",
+			map[string]string{
+				"email": "",
+				"phone": "234567890",
+			},
+			true,
+			http.StatusOK,
+		},
+		{
+			"User has an existing email",
+			map[string]string{
+				"email": "foo@example.com",
+				"phone": "",
+			},
+			false,
+			http.StatusOK,
+		},
+		{
+			"User has an existing email and double email confirmation required",
+			map[string]string{
+				"email": "bar@example.com",
+				"phone": "",
+			},
+			true,
+			http.StatusOK,
+		},
+	}
+
+	for _, c := range cases {
+		ts.Run(c.desc, func() {
+			u, err := models.NewUser(ts.instanceID, "", "", ts.Config.JWT.Aud, nil)
+			require.NoError(ts.T(), err, "Error creating test user model")
+			require.NoError(ts.T(), ts.API.db.Create(u), "Error saving test user")
+			require.NoError(ts.T(), u.SetEmail(ts.API.db, c.userData["email"]), "Error setting user email")
+			require.NoError(ts.T(), u.SetPhone(ts.API.db, c.userData["phone"]), "Error setting user phone")
+
+			token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+			require.NoError(ts.T(), err, "Error generating access token")
+
+			var buffer bytes.Buffer
+			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+				"email": "new@example.com",
+			}))
+			req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
+			req.Header.Set("Content-Type", "application/json")
+			req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+
+			w := httptest.NewRecorder()
+			ts.Config.Mailer.SecureEmailChangeEnabled = c.isSecureEmailChangeEnabled
+			ts.API.handler.ServeHTTP(w, req)
+			require.Equal(ts.T(), c.expectedCode, w.Code)
+		})
+	}
+
+}
+
+func (ts *UserTestSuite) TestUserUpdatePassword() {
 	u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
 
diff --git a/hack/test.env b/hack/test.env
index 9b5d9b89..6d7f74ff 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -61,6 +61,10 @@ GOTRUE_EXTERNAL_SLACK_ENABLED=true
 GOTRUE_EXTERNAL_SLACK_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_SLACK_SECRET=testsecret
 GOTRUE_EXTERNAL_SLACK_REDIRECT_URI=https://identity.services.netlify.com/callback
+GOTRUE_EXTERNAL_WORKOS_ENABLED=true
+GOTRUE_EXTERNAL_WORKOS_CLIENT_ID=testclientid
+GOTRUE_EXTERNAL_WORKOS_SECRET=testsecret
+GOTRUE_EXTERNAL_WORKOS_REDIRECT_URI=https://identity.services.netlify.com/callback
 GOTRUE_EXTERNAL_TWITCH_ENABLED=true
 GOTRUE_EXTERNAL_TWITCH_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_TWITCH_SECRET=testsecret