Repository: supabase/gotrue
Pull request #355 (2022-01-22 04:53:23)

Add Zoom OAuth
## What kind of change does this PR introduce?

Add [Zoom OAuth ](https://marketplace.zoom.us/docs/guides/auth/oauth)

## What is the current behavior?

Login with Zoom was not available.

## What is the new behavior?

Now the users can configure the Zoom OAuth.

## Additional context

NA



Patch:
diff --git a/README.md b/README.md
index 5b21c630..73d67f67 100644
--- a/README.md
+++ b/README.md
@@ -515,30 +515,6 @@ Returns the publicly available settings for this gotrue instance.
 }
 ```
 
-### **POST, PUT /admin/users/<user_id>**
-
-Creates (POST) or Updates (PUT) the user based on the `user_id` specified. The `ban_duration` field accepts the following time units: "ns", "us", "ms", "s", "m", "h". See [`time.ParseDuration`](https://pkg.go.dev/time#ParseDuration) for more details on the format used.
-
-```js
-headers:
-{
-  "Authorization": "Bearer eyJhbGciOiJI...M3A90LCkxxtX9oNP9KZO" // admin role required
-}
-
-body:
-{
-  "role": "test-user",
-  "email": "email@example.com",
-  "phone": "12345678",
-  "password": "secret", // only if type = signup
-  "email_confirm": true,
-  "phone_confirm": true,
-  "user_metadata": {},
-  "app_metadata": {},
-  "ban_duration": "24h" or "none" // to unban a user
-}
-```
-
 ### **POST /admin/generate_link**
 
 Returns the corresponding email action link based on the type specified.
@@ -737,12 +713,9 @@ or show an account confirmed/welcome message in the case of `signup`, or direct
 
 One-Time-Password. Will deliver a magiclink or sms otp to the user depending on whether the request body contains an "email" or "phone" key.
 
-If `"create_user": true`, user will not be automatically signed up if the user doesn't exist.
-
 ```js
 {
   "phone": "12345678" // follows the E.164 format
-  "create_user": true
 }
 
 OR
@@ -750,7 +723,6 @@ OR
 // exactly the same as /magiclink
 {
   "email": "email@example.com"
-  "create_user": true
 }
 ```
 
diff --git a/api/admin.go b/api/admin.go
index 8022cda8..c583ac72 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -5,8 +5,6 @@ import (
 	"encoding/json"
 	"errors"
 	"net/http"
-	"strings"
-	"time"
 
 	"github.com/go-chi/chi"
 	"github.com/gofrs/uuid"
@@ -25,7 +23,6 @@ type adminUserParams struct {
 	PhoneConfirm bool                   `json:"phone_confirm"`
 	UserMetaData map[string]interface{} `json:"user_metadata"`
 	AppMetaData  map[string]interface{} `json:"app_metadata"`
-	BanDuration  string                 `json:"ban_duration"`
 }
 
 func (a *API) loadUser(w http.ResponseWriter, r *http.Request) (context.Context, error) {
@@ -159,22 +156,6 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
 			}
 		}
 
-		if params.BanDuration != "" {
-			if params.BanDuration == "none" {
-				user.BannedUntil = nil
-			} else {
-				duration, terr := time.ParseDuration(params.BanDuration)
-				if terr != nil {
-					return badRequestError("Invalid format for ban_duration: %v", terr)
-				}
-				t := time.Now().Add(duration)
-				user.BannedUntil = &t
-			}
-			if terr := user.UpdateBannedUntil(tx); terr != nil {
-				return terr
-			}
-		}
-
 		if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserModifiedAction, map[string]interface{}{
 			"user_id":    user.ID,
 			"user_email": user.Email,
@@ -189,9 +170,6 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
 		if errors.Is(err, invalidPasswordLengthError(config)) {
 			return err
 		}
-		if strings.Contains(err.Error(), "Invalid format for ban_duration") {
-			return err
-		}
 		return internalServerError("Error updating user").WithInternalError(err)
 	}
 
@@ -263,15 +241,6 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
 	user.AppMetaData["provider"] = "email"
 	user.AppMetaData["providers"] = []string{"email"}
 
-	if params.BanDuration != "" {
-		duration, terr := time.ParseDuration(params.BanDuration)
-		if terr != nil {
-			return badRequestError("Invalid format for ban_duration: %v", terr)
-		}
-		t := time.Now().Add(duration)
-		user.BannedUntil = &t
-	}
-
 	err = a.db.Transaction(func(tx *storage.Connection) error {
 		if terr := models.NewAuditLogEntry(tx, instanceID, adminUser, models.UserSignedUpAction, map[string]interface{}{
 			"user_id":    user.ID,
@@ -309,9 +278,6 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {
 	})
 
 	if err != nil {
-		if strings.Contains(err.Error(), "Invalid format for ban_duration") {
-			return err
-		}
 		return internalServerError("Database error creating new user").WithInternalError(err)
 	}
 
diff --git a/api/errors.go b/api/errors.go
index f839357d..99232184 100644
--- a/api/errors.go
+++ b/api/errors.go
@@ -181,18 +181,6 @@ func (e *OTPError) Error() string {
 	return fmt.Sprintf("%s: %s", e.Err, e.Description)
 }
 
-// WithInternalError adds internal error information to the error
-func (e *OTPError) WithInternalError(err error) *OTPError {
-	e.InternalError = err
-	return e
-}
-
-// WithInternalMessage adds internal message information to the error
-func (e *OTPError) WithInternalMessage(fmtString string, args ...interface{}) *OTPError {
-	e.InternalMessage = fmt.Sprintf(fmtString, args...)
-	return e
-}
-
 // Cause returns the root cause error
 func (e *OTPError) Cause() error {
 	if e.InternalError != nil {
@@ -256,11 +244,6 @@ func handleError(err error, w http.ResponseWriter, r *http.Request) {
 		if jsonErr := sendJSON(w, http.StatusBadRequest, e); jsonErr != nil {
 			handleError(jsonErr, w, r)
 		}
-	case *OTPError:
-		log.WithError(e.Cause()).Info(e.Error())
-		if jsonErr := sendJSON(w, http.StatusBadRequest, e); jsonErr != nil {
-			handleError(jsonErr, w, r)
-		}
 	case ErrorCause:
 		handleError(e.Cause(), w, r)
 	default:
diff --git a/api/external.go b/api/external.go
index 328d4243..d2899af9 100644
--- a/api/external.go
+++ b/api/external.go
@@ -225,10 +225,6 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
 				}
 			}
 
-			if user.IsBanned() {
-				return unauthorizedError("User is unauthorized")
-			}
-
 			if !user.IsConfirmed() {
 				if !emailData.Verified && !config.Mailer.Autoconfirm {
 					mailer := a.Mailer(ctx)
@@ -412,6 +408,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
 		return provider.NewTwitterProvider(config.External.Twitter, scopes)
 	case "saml":
 		return provider.NewSamlProvider(config.External.Saml, a.db, getInstanceID(ctx))
+	case "zoom":
+		return provider.NewZoomProvider(config.External.Zoom)
 	default:
 		return nil, fmt.Errorf("Provider %s could not be found", name)
 	}
diff --git a/api/otp.go b/api/otp.go
index 6b8a86b8..05bf94e8 100644
--- a/api/otp.go
+++ b/api/otp.go
@@ -14,9 +14,8 @@ import (
 
 // OtpParams contains the request body params for the otp endpoint
 type OtpParams struct {
-	Email      string `json:"email"`
-	Phone      string `json:"phone"`
-	CreateUser bool   `json:"create_user"`
+	Email string `json:"email"`
+	Phone string `json:"phone"`
 }
 
 // SmsParams contains the request body params for sms otp
@@ -26,9 +25,7 @@ type SmsParams struct {
 
 // Otp returns the MagicLink or SmsOtp handler based on the request body params
 func (a *API) Otp(w http.ResponseWriter, r *http.Request) error {
-	params := &OtpParams{
-		CreateUser: true,
-	}
+	params := &OtpParams{}
 	body, err := ioutil.ReadAll(r.Body)
 	jsonDecoder := json.NewDecoder(bytes.NewReader(body))
 	if err = jsonDecoder.Decode(params); err != nil {
@@ -39,11 +36,6 @@ func (a *API) Otp(w http.ResponseWriter, r *http.Request) error {
 	}
 
 	r.Body = ioutil.NopCloser(strings.NewReader(string(body)))
-
-	if !a.shouldCreateUser(r, params) {
-		return badRequestError("Signups not allowed for otp")
-	}
-
 	if params.Email != "" {
 		return a.MagicLink(w, r)
 	} else if params.Phone != "" {
@@ -116,22 +108,3 @@ func (a *API) SmsOtp(w http.ResponseWriter, r *http.Request) error {
 
 	return sendJSON(w, http.StatusOK, make(map[string]string))
 }
-
-func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) bool {
-	if !params.CreateUser {
-		ctx := r.Context()
-		instanceID := getInstanceID(ctx)
-		aud := a.requestAud(ctx, r)
-		var err error
-		if params.Email != "" {
-			_, err = models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, aud)
-		} else if params.Phone != "" {
-			_, err = models.FindUserByPhoneAndAudience(a.db, instanceID, params.Phone, aud)
-		}
-
-		if err != nil && models.IsNotFoundError(err) {
-			return false
-		}
-	}
-	return true
-}
diff --git a/api/provider/zoom.go b/api/provider/zoom.go
new file mode 100644
index 00000000..4ab322a3
--- /dev/null
+++ b/api/provider/zoom.go
@@ -0,0 +1,93 @@
+package provider
+
+import (
+	"context"
+	"errors"
+	"strings"
+
+	"github.com/netlify/gotrue/conf"
+	"golang.org/x/oauth2"
+)
+
+const (
+	defaultZoomAuthBase = "zoom.us"
+	defaultZoomAPIBase  = "api.zoom.us"
+)
+
+type zoomProvider struct {
+	*oauth2.Config
+	APIPath string
+}
+
+type zoomUser struct {
+	ID            string `json:"id"`
+	FirstName     string `json:"first_name"`
+	LastName      string `json:"last_name"`
+	Email         string `json:"email"`
+	EmailVerified int    `json:"verified"`
+	AvatarURL     string `json:"pic_url"`
+}
+
+// NewZoomProvider creates a Zoom account provider.
+func NewZoomProvider(ext conf.OAuthProviderConfiguration) (OAuthProvider, error) {
+	if err := ext.Validate(); err != nil {
+		return nil, err
+	}
+
+	apiPath := chooseHost(ext.URL, defaultZoomAPIBase) + "/v2"
+	authPath := chooseHost(ext.URL, defaultZoomAuthBase) + "/oauth"
+
+	return &zoomProvider{
+		Config: &oauth2.Config{
+			ClientID:     ext.ClientID,
+			ClientSecret: ext.Secret,
+			Endpoint: oauth2.Endpoint{
+				AuthURL:  authPath + "/authorize",
+				TokenURL: authPath + "/token",
+			},
+			RedirectURL: ext.RedirectURI,
+		},
+		APIPath: apiPath,
+	}, nil
+}
+
+func (g zoomProvider) GetOAuthToken(code string) (*oauth2.Token, error) {
+	return g.Exchange(oauth2.NoContext, code)
+}
+
+func (g zoomProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
+	var u zoomUser
+	if err := makeRequest(ctx, tok, g.Config, g.APIPath+"/users/me", &u); err != nil {
+		return nil, err
+	}
+
+	if u.Email == "" {
+		return nil, errors.New("Unable to find email with Zoom provider")
+	}
+
+	verified := false
+	if u.EmailVerified == 1 {
+		verified = true
+	}
+
+	return &UserProvidedData{
+		Metadata: &Claims{
+			Issuer:        g.APIPath,
+			Subject:       u.ID,
+			Name:          strings.TrimSpace(u.FirstName + " " + u.LastName),
+			Picture:       u.AvatarURL,
+			Email:         u.Email,
+			EmailVerified: verified,
+
+			// To be deprecated
+			AvatarURL:  u.AvatarURL,
+			FullName:   strings.TrimSpace(u.FirstName + " " + u.LastName),
+			ProviderId: u.ID,
+		},
+		Emails: []Email{{
+			Email:    u.Email,
+			Verified: verified,
+			Primary:  true,
+		}},
+	}, nil
+}
diff --git a/api/settings.go b/api/settings.go
index c53f751d..56e760ef 100644
--- a/api/settings.go
+++ b/api/settings.go
@@ -20,6 +20,7 @@ type ProviderSettings struct {
 	Email     bool `json:"email"`
 	Phone     bool `json:"phone"`
 	SAML      bool `json:"saml"`
+	Zoom      bool `json:"zoom"`
 }
 
 type ProviderLabels struct {
@@ -57,6 +58,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
 			Email:     config.External.Email.Enabled,
 			Phone:     config.External.Phone.Enabled,
 			SAML:      config.External.Saml.Enabled,
+			Zoom:      config.External.Zoom.Enabled,
 		},
 		ExternalLabels: ProviderLabels{
 			SAML: config.External.Saml.Name,
diff --git a/api/token.go b/api/token.go
index 2cd63d2b..39e3ab0c 100644
--- a/api/token.go
+++ b/api/token.go
@@ -173,7 +173,7 @@ func (a *API) ResourceOwnerPasswordGrant(ctx context.Context, w http.ResponseWri
 		return internalServerError("Database error querying schema").WithInternalError(err)
 	}
 
-	if user.IsBanned() || !user.Authenticate(params.Password) {
+	if !user.Authenticate(params.Password) {
 		return oauthError("invalid_grant", InvalidLoginMessage)
 	}
 
@@ -235,10 +235,6 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
 		return internalServerError(err.Error())
 	}
 
-	if user.IsBanned() {
-		return oauthError("invalid_grant", "Invalid Refresh Token")
-	}
-
 	if !(config.External.Email.Enabled && config.External.Phone.Enabled) {
 		providers, err := models.FindProvidersByUser(a.db, user)
 		if err != nil {
@@ -401,9 +397,6 @@ func (a *API) IdTokenGrant(ctx context.Context, w http.ResponseWriter, r *http.R
 			if email != "" {
 				identity.IdentityData["email"] = email
 			}
-			if user.IsBanned() {
-				return oauthError("invalid_grant", "invalid id token grant")
-			}
 			if terr = tx.UpdateOnly(identity, "identity_data", "last_sign_in_at"); terr != nil {
 				return terr
 			}
diff --git a/api/verify.go b/api/verify.go
index 8d517283..202a28c8 100644
--- a/api/verify.go
+++ b/api/verify.go
@@ -79,7 +79,9 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
 	err = a.db.Transaction(func(tx *storage.Connection) error {
 		var terr error
 		switch params.Type {
-		case signupVerification, inviteVerification:
+		case signupVerification:
+			user, terr = a.signupVerify(ctx, tx, params)
+		case inviteVerification:
 			user, terr = a.signupVerify(ctx, tx, params)
 		case recoveryVerification, magicLinkVerification:
 			user, terr = a.recoverVerify(ctx, tx, params)
@@ -88,7 +90,7 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
 			if user == nil && terr == nil {
 				// when double confirmation is required
 				rurl := a.prepRedirectURL("Confirmation link accepted. Please proceed to confirm link sent to the other email", params.RedirectTo)
-				http.Redirect(w, r, rurl, http.StatusSeeOther)
+				http.Redirect(w, r, rurl, http.StatusFound)
 				return nil
 			}
 		case smsVerification:
@@ -110,7 +112,7 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
 			if errors.As(terr, &e) {
 				if errors.Is(e.InternalError, redirectWithQueryError) {
 					rurl := a.prepErrorRedirectURL(e, r, params.RedirectTo)
-					http.Redirect(w, r, rurl, http.StatusSeeOther)
+					http.Redirect(w, r, rurl, http.StatusFound)
 					return nil
 				}
 			}
@@ -164,10 +166,6 @@ func (a *API) signupVerify(ctx context.Context, conn *storage.Connection, params
 		return nil, internalServerError("Database error finding user").WithInternalError(err)
 	}
 
-	if user.IsBanned() {
-		return nil, unauthorizedError("Error confirming user").WithInternalError(redirectWithQueryError)
-	}
-
 	nextDay := user.ConfirmationSentAt.Add(24 * time.Hour)
 	if user.ConfirmationSentAt != nil && time.Now().After(nextDay) {
 		return nil, expiredTokenError("Confirmation token expired").WithInternalError(redirectWithQueryError)
@@ -219,10 +217,6 @@ func (a *API) recoverVerify(ctx context.Context, conn *storage.Connection, param
 		return nil, internalServerError("Database error finding user").WithInternalError(err)
 	}
 
-	if user.IsBanned() {
-		return nil, unauthorizedError("Error confirming user").WithInternalError(redirectWithQueryError)
-	}
-
 	nextDay := user.RecoverySentAt.Add(24 * time.Hour)
 	if user.RecoverySentAt != nil && time.Now().After(nextDay) {
 		return nil, expiredTokenError("Recovery token expired").WithInternalError(redirectWithQueryError)
@@ -264,11 +258,6 @@ func (a *API) smsVerify(ctx context.Context, conn *storage.Connection, params *V
 		}
 		return nil, internalServerError("Database error finding user").WithInternalError(err)
 	}
-
-	if user.IsBanned() {
-		return nil, unauthorizedError("Error confirming user").WithInternalError(redirectWithQueryError)
-	}
-
 	now := time.Now()
 	expiresAt := user.ConfirmationSentAt.Add(time.Second * time.Duration(config.Sms.OtpExp))
 
@@ -329,10 +318,6 @@ func (a *API) emailChangeVerify(ctx context.Context, conn *storage.Connection, p
 		return nil, internalServerError("Database error finding user").WithInternalError(err)
 	}
 
-	if user.IsBanned() {
-		return nil, unauthorizedError("Error confirming user").WithInternalError(redirectWithQueryError)
-	}
-
 	nextDay := user.EmailChangeSentAt.Add(24 * time.Hour)
 	if user.EmailChangeSentAt != nil && time.Now().After(nextDay) {
 		err = a.db.Transaction(func(tx *storage.Connection) error {
diff --git a/conf/configuration.go b/conf/configuration.go
index 893c386d..7d159164 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -97,6 +97,7 @@ type ProviderConfiguration struct {
 	Email       EmailProviderConfiguration `json:"email"`
 	Phone       PhoneProviderConfiguration `json:"phone"`
 	Saml        SamlProviderConfiguration  `json:"saml"`
+	Zoom        OAuthProviderConfiguration `json:"zoom"`
 	IosBundleId string                     `json:"ios_bundle_id" split_words:"true"`
 	RedirectURL string                     `json:"redirect_url"`
 }
diff --git a/example.env b/example.env
index 60f009c9..729a2a0f 100644
--- a/example.env
+++ b/example.env
@@ -137,6 +137,12 @@ GOTRUE_EXTERNAL_SLACK_CLIENT_ID=""
 GOTRUE_EXTERNAL_SLACK_SECRET=""
 GOTRUE_EXTERNAL_SLACK_REDIRECT_URI="https://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"
+
 # Phone provider config
 GOTRUE_SMS_AUTOCONFIRM="false"
 GOTRUE_SMS_MAX_FREQUENCY="5s"
diff --git a/migrations/20220114185221_update_user_idx.up.sql b/migrations/20220114185221_update_user_idx.up.sql
deleted file mode 100644
index 7572e047..00000000
--- a/migrations/20220114185221_update_user_idx.up.sql
+++ /dev/null
@@ -1,4 +0,0 @@
--- updates users_instance_id_email_idx definition
-
-DROP INDEX IF EXISTS users_instance_id_email_idx;
-CREATE INDEX IF NOT EXISTS users_instance_id_email_idx on users using btree (instance_id, lower(email));
diff --git a/migrations/20220114185340_add_banned_until.up.sql b/migrations/20220114185340_add_banned_until.up.sql
deleted file mode 100644
index c2af132b..00000000
--- a/migrations/20220114185340_add_banned_until.up.sql
+++ /dev/null
@@ -1,4 +0,0 @@
--- adds banned_until column
-
-ALTER TABLE auth.users
-ADD COLUMN IF NOT EXISTS banned_until timestamptz NULL;
diff --git a/models/user.go b/models/user.go
index d2f7083e..c7304465 100644
--- a/models/user.go
+++ b/models/user.go
@@ -58,9 +58,8 @@ type User struct {
 	IsSuperAdmin bool       `json:"-" db:"is_super_admin"`
 	Identities   []Identity `json:"identities" has_many:"identities"`
 
-	CreatedAt   time.Time  `json:"created_at" db:"created_at"`
-	UpdatedAt   time.Time  `json:"updated_at" db:"updated_at"`
-	BannedUntil *time.Time `json:"banned_until,omitempty" db:"banned_until"`
+	CreatedAt time.Time `json:"created_at" db:"created_at"`
+	UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
 }
 
 // NewUser initializes a new user from an email, password and user data.
@@ -148,9 +147,6 @@ func (u *User) BeforeSave(tx *pop.Connection) error {
 	if u.LastSignInAt != nil && u.LastSignInAt.IsZero() {
 		u.LastSignInAt = nil
 	}
-	if u.BannedUntil != nil && u.BannedUntil.IsZero() {
-		u.BannedUntil = nil
-	}
 	return nil
 }
 
@@ -460,16 +456,3 @@ func IsDuplicatedPhone(tx *storage.Connection, instanceID uuid.UUID, phone, aud
 	}
 	return true, nil
 }
-
-// IsBanned checks if a user is banned or not
-func (u *User) IsBanned() bool {
-	if u.BannedUntil == nil {
-		return false
-	}
-	return time.Now().Before(*u.BannedUntil)
-}
-
-func (u *User) UpdateBannedUntil(tx *storage.Connection) error {
-	return tx.UpdateOnly(u, "banned_until")
-
-}


Test patch:
diff --git a/api/admin_test.go b/api/admin_test.go
index 9c421630..ca1d61dc 100644
--- a/api/admin_test.go
+++ b/api/admin_test.go
@@ -301,20 +301,6 @@ func (ts *AdminTestSuite) TestAdminUserCreate() {
 				"isAuthenticated": false,
 			},
 		},
-		{
-			desc: "Ban created user",
-			params: map[string]interface{}{
-				"email":        "test4@example.com",
-				"phone":        "",
-				"password":     "test1",
-				"ban_duration": "24h",
-			},
-			expected: map[string]interface{}{
-				"email":           "test4@example.com",
-				"phone":           "",
-				"isAuthenticated": true,
-			},
-		},
 	}
 
 	for _, c := range cases {
@@ -393,7 +379,6 @@ func (ts *AdminTestSuite) TestAdminUserUpdate() {
 		"user_metadata": map[string]interface{}{
 			"name": "David",
 		},
-		"ban_duration": "24h",
 	}))
 
 	// Setup request
@@ -416,7 +401,6 @@ func (ts *AdminTestSuite) TestAdminUserUpdate() {
 	assert.Len(ts.T(), data.AppMetaData["roles"], 2)
 	assert.Contains(ts.T(), data.AppMetaData["roles"], "writer")
 	assert.Contains(ts.T(), data.AppMetaData["roles"], "editor")
-	assert.NotNil(ts.T(), data.BannedUntil)
 }
 
 // TestAdminUserUpdate tests API /admin/user route (UPDATE) as system user
@@ -488,29 +472,6 @@ func (ts *AdminTestSuite) TestAdminUserUpdatePasswordFailed() {
 	})
 }
 
-func (ts *AdminTestSuite) TestAdminUserUpdateBannedUntilFailed() {
-	u, err := models.NewUser(ts.instanceID, "test1@example.com", "test", ts.Config.JWT.Aud, nil)
-	require.NoError(ts.T(), err, "Error making new user")
-	require.NoError(ts.T(), ts.API.db.Create(u), "Error creating user")
-
-	var updateEndpoint = fmt.Sprintf("/admin/users/%s", u.ID)
-	ts.Config.PasswordMinLength = 6
-	ts.Run("Incorrect format for ban_duration", func() {
-		var buffer bytes.Buffer
-		require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-			"ban_duration": "24",
-		}))
-
-		// Setup request
-		w := httptest.NewRecorder()
-		req := httptest.NewRequest(http.MethodPut, updateEndpoint, &buffer)
-		req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ts.token))
-
-		ts.API.handler.ServeHTTP(w, req)
-		require.Equal(ts.T(), http.StatusBadRequest, w.Code)
-	})
-}
-
 // TestAdminUserDelete tests API /admin/user route (DELETE)
 func (ts *AdminTestSuite) TestAdminUserDelete() {
 	u, err := models.NewUser(ts.instanceID, "test-delete@example.com", "test", ts.Config.JWT.Aud, nil)
diff --git a/api/external_github_test.go b/api/external_github_test.go
index 9ebcda49..f80b182a 100644
--- a/api/external_github_test.go
+++ b/api/external_github_test.go
@@ -5,11 +5,8 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"net/url"
-	"time"
 
 	jwt "github.com/golang-jwt/jwt"
-	"github.com/netlify/gotrue/models"
-	"github.com/stretchr/testify/require"
 )
 
 func (ts *ExternalTestSuite) TestSignupExternalGithub() {
@@ -204,23 +201,3 @@ func (ts *ExternalTestSuite) TestSignupExternalGitHubErrorWhenVerifiedFalse() {
 	ts.Equal("Unverified email with github", v.Get("error_description"))
 	assertAuthorizationFailure(ts, u, "", "", "")
 }
-
-func (ts *ExternalTestSuite) TestSignupExternalGitHubErrorWhenUserBanned() {
-	tokenCount, userCount := 0, 0
-	code := "authcode"
-	emails := `[{"email":"github@example.com", "primary": true, "verified": true}]`
-	server := GitHubTestSignupSetup(ts, &tokenCount, &userCount, code, emails)
-	defer server.Close()
-
-	u := performAuthorization(ts, "github", code, "")
-	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "github@example.com", "GitHub Test", "123", "http://example.com/avatar")
-
-	user, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "github@example.com", ts.Config.JWT.Aud)
-	require.NoError(ts.T(), err)
-	t := time.Now().Add(24 * time.Hour)
-	user.BannedUntil = &t
-	require.NoError(ts.T(), ts.API.db.UpdateOnly(user, "banned_until"))
-
-	u = performAuthorization(ts, "github", code, "")
-	assertAuthorizationFailure(ts, u, "User is unauthorized", "unauthorized_client", "")
-}
diff --git a/api/external_zoom_test.go b/api/external_zoom_test.go
new file mode 100644
index 00000000..63b7e809
--- /dev/null
+++ b/api/external_zoom_test.go
@@ -0,0 +1,167 @@
+package api
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+
+	jwt "github.com/golang-jwt/jwt"
+)
+
+const (
+	zoomUser           string = `{"id":"zoomUserId","first_name":"John","last_name": "Doe","email": "zoom@example.com","verified": 1,"pic_url":"http://example.com/avatar"}`
+	zoomUserWrongEmail string = `{"id":"zoomUserId","first_name":"John","last_name": "Doe","email": "other@example.com","verified": 1,"pic_url":"http://example.com/avatar"}`
+	zoomUserNoEmail    string = `{"id":"zoomUserId","first_name":"John","last_name": "Doe","verified": 1,"pic_url":"http://example.com/avatar"}`
+)
+
+func (ts *ExternalTestSuite) TestSignupExternalZoom() {
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=zoom", 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.Zoom.RedirectURI, q.Get("redirect_uri"))
+	ts.Equal(ts.Config.External.Zoom.ClientID, q.Get("client_id"))
+	ts.Equal("code", q.Get("response_type"))
+
+	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("zoom", claims.Provider)
+	ts.Equal(ts.Config.SiteURL, claims.SiteURL)
+}
+
+func ZoomTestSignupSetup(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 "/oauth/token":
+			*tokenCount++
+			ts.Equal(code, r.FormValue("code"))
+			ts.Equal("authorization_code", r.FormValue("grant_type"))
+			ts.Equal(ts.Config.External.Zoom.RedirectURI, r.FormValue("redirect_uri"))
+
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprint(w, `{"access_token":"zoom_token","expires_in":100000}`)
+		case "/v2/users/me":
+			*userCount++
+			ts.Contains(r.Header, "Authorization")
+			w.Header().Add("Content-Type", "application/json")
+			fmt.Fprint(w, user)
+		default:
+			w.WriteHeader(500)
+			ts.Fail("unknown zoom oauth call %s", r.URL.Path)
+		}
+	}))
+
+	ts.Config.External.Zoom.URL = server.URL
+
+	return server
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalZoomAuthorizationCode() {
+	ts.Config.DisableSignup = false
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "zoom", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "zoom@example.com", "John Doe", "zoomUserId", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalZoomDisableSignupErrorWhenNoUser() {
+	ts.Config.DisableSignup = true
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "zoom", code, "")
+
+	assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "zoom@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalZoomDisableSignupErrorWhenEmptyEmail() {
+	ts.Config.DisableSignup = true
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUserNoEmail)
+	defer server.Close()
+
+	u := performAuthorization(ts, "zoom", code, "")
+
+	assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "zoom@example.com")
+}
+
+func (ts *ExternalTestSuite) TestSignupExternalZoomDisableSignupSuccessWithPrimaryEmail() {
+	ts.Config.DisableSignup = true
+
+	ts.createUser("zoomUserId", "zoom@example.com", "John Doe", "http://example.com/avatar", "")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "zoom", code, "")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "zoom@example.com", "John Doe", "zoomUserId", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalZoomSuccessWhenMatchingToken() {
+	ts.createUser("zoomUserId", "zoom@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
+	defer server.Close()
+
+	u := performAuthorization(ts, "zoom", code, "invite_token")
+
+	assertAuthorizationSuccess(ts, u, tokenCount, userCount, "zoom@example.com", "John Doe", "zoomUserId", "http://example.com/avatar")
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalZoomErrorWhenNoMatchingToken() {
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "zoom", "invite_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalZoomErrorWhenWrongToken() {
+	ts.createUser("zoomUserId", "zoom@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUser)
+	defer server.Close()
+
+	w := performAuthorizationRequest(ts, "zoom", "wrong_token")
+	ts.Require().Equal(http.StatusNotFound, w.Code)
+}
+
+func (ts *ExternalTestSuite) TestInviteTokenExternalZoomErrorWhenEmailDoesntMatch() {
+	ts.createUser("zoomUserId", "zoom@example.com", "", "", "invite_token")
+
+	tokenCount, userCount := 0, 0
+	code := "authcode"
+	server := ZoomTestSignupSetup(ts, &tokenCount, &userCount, code, zoomUserWrongEmail)
+	defer server.Close()
+
+	u := performAuthorization(ts, "zoom", code, "invite_token")
+
+	assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "")
+}
diff --git a/api/otp_test.go b/api/otp_test.go
deleted file mode 100644
index 97851b8d..00000000
--- a/api/otp_test.go
+++ /dev/null
@@ -1,134 +0,0 @@
-package api
-
-import (
-	"bytes"
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"testing"
-
-	"github.com/gofrs/uuid"
-	"github.com/netlify/gotrue/conf"
-	"github.com/netlify/gotrue/models"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"github.com/stretchr/testify/suite"
-)
-
-type OtpTestSuite struct {
-	suite.Suite
-	API    *API
-	Config *conf.Configuration
-
-	instanceID uuid.UUID
-}
-
-func TestOtp(t *testing.T) {
-	api, config, instanceID, err := setupAPIForTestForInstance()
-	require.NoError(t, err)
-
-	ts := &OtpTestSuite{
-		API:        api,
-		Config:     config,
-		instanceID: instanceID,
-	}
-	defer api.db.Close()
-
-	suite.Run(t, ts)
-}
-
-func (ts *OtpTestSuite) SetupTest() {
-	models.TruncateAll(ts.API.db)
-}
-
-func (ts *OtpTestSuite) TestOtp() {
-	cases := []struct {
-		desc     string
-		params   OtpParams
-		expected struct {
-			code     int
-			response map[string]interface{}
-		}
-	}{
-		{
-			"Test Success Magiclink Otp",
-			OtpParams{
-				Email:      "test@example.com",
-				CreateUser: true,
-			},
-			struct {
-				code     int
-				response map[string]interface{}
-			}{
-				http.StatusOK,
-				make(map[string]interface{}),
-			},
-		},
-		{
-			"Test Failure Pass Both Email & Phone",
-			OtpParams{
-				Email:      "test@example.com",
-				Phone:      "123456789",
-				CreateUser: true,
-			},
-			struct {
-				code     int
-				response map[string]interface{}
-			}{
-				http.StatusBadRequest,
-				map[string]interface{}{
-					"code": float64(http.StatusBadRequest),
-					"msg":  "Only an email address or phone number should be provided",
-				},
-			},
-		},
-	}
-
-	for _, c := range cases {
-		ts.Run(c.desc, func() {
-			var buffer bytes.Buffer
-			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.params))
-
-			req := httptest.NewRequest(http.MethodPost, "/otp", &buffer)
-			req.Header.Set("Content-Type", "application/json")
-
-			w := httptest.NewRecorder()
-
-			ts.API.handler.ServeHTTP(w, req)
-
-			require.Equal(ts.T(), c.expected.code, w.Code)
-
-			data := make(map[string]interface{})
-			require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
-
-			// response should be empty
-			assert.Equal(ts.T(), data, c.expected.response)
-		})
-	}
-}
-
-func (ts *OtpTestSuite) TestNoSignupsForOtp() {
-	var buffer bytes.Buffer
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"email":       "newuser@example.com",
-		"create_user": false,
-	}))
-
-	req := httptest.NewRequest(http.MethodPost, "/otp", &buffer)
-	req.Header.Set("Content-Type", "application/json")
-
-	w := httptest.NewRecorder()
-
-	ts.API.handler.ServeHTTP(w, req)
-
-	require.Equal(ts.T(), http.StatusBadRequest, w.Code)
-
-	data := make(map[string]interface{})
-	require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
-
-	// response should be empty
-	assert.Equal(ts.T(), data, map[string]interface{}{
-		"code": float64(http.StatusBadRequest),
-		"msg":  "Signups not allowed for otp",
-	})
-}
diff --git a/api/settings_test.go b/api/settings_test.go
index c706bd48..922b5ff0 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.Zoom)
 }
 
 func TestSettings_EmailDisabled(t *testing.T) {
diff --git a/api/token_test.go b/api/token_test.go
index eaa5dcb5..9f7ad83e 100644
--- a/api/token_test.go
+++ b/api/token_test.go
@@ -2,12 +2,10 @@ package api
 
 import (
 	"bytes"
-	"encoding/json"
 	"net/http"
 	"net/http/httptest"
 	"os"
 	"testing"
-	"time"
 
 	"github.com/gofrs/uuid"
 	"github.com/netlify/gotrue/conf"
@@ -22,8 +20,8 @@ type TokenTestSuite struct {
 	API    *API
 	Config *conf.Configuration
 
-	RefreshToken *models.RefreshToken
-	instanceID   uuid.UUID
+	token      string
+	instanceID uuid.UUID
 }
 
 func TestToken(t *testing.T) {
@@ -42,19 +40,7 @@ func TestToken(t *testing.T) {
 }
 
 func (ts *TokenTestSuite) SetupTest() {
-	ts.RefreshToken = nil
 	models.TruncateAll(ts.API.db)
-
-	// Create user & refresh token
-	u, err := models.NewUser(ts.instanceID, "test@example.com", "password", ts.Config.JWT.Aud, nil)
-	require.NoError(ts.T(), err, "Error creating test user model")
-	t := time.Now()
-	u.EmailConfirmedAt = &t
-	u.BannedUntil = nil
-	require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
-
-	ts.RefreshToken, err = models.GrantAuthenticatedUser(ts.API.db, u)
-	require.NoError(ts.T(), err, "Error creating refresh token")
 }
 
 func (ts *TokenTestSuite) TestRateLimitToken() {
@@ -87,80 +73,3 @@ func (ts *TokenTestSuite) TestRateLimitToken() {
 	ts.API.handler.ServeHTTP(w, req)
 	assert.Equal(ts.T(), http.StatusBadRequest, w.Code)
 }
-
-func (ts *TokenTestSuite) TestTokenPasswordGrantSuccess() {
-	var buffer bytes.Buffer
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"email":    "test@example.com",
-		"password": "password",
-	}))
-
-	req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &buffer)
-	req.Header.Set("Content-Type", "application/json")
-
-	w := httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
-	assert.Equal(ts.T(), http.StatusOK, w.Code)
-}
-
-func (ts *TokenTestSuite) TestTokenRefreshTokenGrantSuccess() {
-	var buffer bytes.Buffer
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"refresh_token": ts.RefreshToken.Token,
-	}))
-
-	req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer)
-	req.Header.Set("Content-Type", "application/json")
-
-	w := httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
-	assert.Equal(ts.T(), http.StatusOK, w.Code)
-}
-
-func (ts *TokenTestSuite) TestTokenPasswordGrantFailure() {
-	u := ts.createBannedUser()
-
-	var buffer bytes.Buffer
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"email":    u.GetEmail(),
-		"password": "password",
-	}))
-
-	req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=password", &buffer)
-	req.Header.Set("Content-Type", "application/json")
-
-	w := httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
-	assert.Equal(ts.T(), http.StatusBadRequest, w.Code)
-}
-
-func (ts *TokenTestSuite) TestTokenRefreshTokenGrantFailure() {
-	_ = ts.createBannedUser()
-
-	var buffer bytes.Buffer
-	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
-		"refresh_token": ts.RefreshToken.Token,
-	}))
-
-	req := httptest.NewRequest(http.MethodPost, "http://localhost/token?grant_type=refresh_token", &buffer)
-	req.Header.Set("Content-Type", "application/json")
-
-	w := httptest.NewRecorder()
-	ts.API.handler.ServeHTTP(w, req)
-	assert.Equal(ts.T(), http.StatusBadRequest, w.Code)
-}
-
-func (ts *TokenTestSuite) createBannedUser() *models.User {
-	u, err := models.NewUser(ts.instanceID, "banned@example.com", "password", ts.Config.JWT.Aud, nil)
-	require.NoError(ts.T(), err, "Error creating test user model")
-	t := time.Now()
-	u.EmailConfirmedAt = &t
-	t = t.Add(24 * time.Hour)
-	u.BannedUntil = &t
-	require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test banned user")
-
-	ts.RefreshToken, err = models.GrantAuthenticatedUser(ts.API.db, u)
-	require.NoError(ts.T(), err, "Error creating refresh token")
-
-	return u
-}
diff --git a/api/verify_test.go b/api/verify_test.go
index 0e44da19..fb87ad24 100644
--- a/api/verify_test.go
+++ b/api/verify_test.go
@@ -119,7 +119,7 @@ func (ts *VerifyTestSuite) TestExpiredConfirmationToken() {
 	w := httptest.NewRecorder()
 
 	ts.API.handler.ServeHTTP(w, req)
-	assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
+	assert.Equal(ts.T(), http.StatusFound, w.Code)
 
 	url, err := w.Result().Location()
 	require.NoError(ts.T(), err)
@@ -140,7 +140,7 @@ func (ts *VerifyTestSuite) TestInvalidSmsOtp() {
 	}
 
 	expectedResponse := expected{
-		code:      http.StatusSeeOther,
+		code:      http.StatusFound,
 		fragments: "error_code=410&error_description=Otp+has+expired+or+is+invalid",
 	}
 
@@ -222,7 +222,7 @@ func (ts *VerifyTestSuite) TestExpiredRecoveryToken() {
 
 	ts.API.handler.ServeHTTP(w, req)
 
-	assert.Equal(ts.T(), http.StatusSeeOther, w.Code, w.Body.String())
+	assert.Equal(ts.T(), http.StatusFound, w.Code, w.Body.String())
 }
 
 func (ts *VerifyTestSuite) TestVerifyPermitedCustomUri() {
@@ -373,87 +373,3 @@ func (ts *VerifyTestSuite) TestVerifySignupWithredirectURLContainedPath() {
 		})
 	}
 }
-
-func (ts *VerifyTestSuite) TestVerifyBannedUser() {
-	u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
-	require.NoError(ts.T(), err)
-	u.ConfirmationToken = "confirmation_token"
-	u.RecoveryToken = "recovery_token"
-	u.EmailChangeTokenCurrent = "current_email_change_token"
-	u.EmailChangeTokenNew = "new_email_change_token"
-	t := time.Now()
-	u.ConfirmationSentAt = &t
-	u.RecoverySentAt = &t
-	u.EmailChangeSentAt = &t
-
-	t = time.Now().Add(24 * time.Hour)
-	u.BannedUntil = &t
-	require.NoError(ts.T(), ts.API.db.Update(u))
-
-	cases := []struct {
-		desc    string
-		payload *VerifyParams
-	}{
-		{
-			"Verify banned user on signup",
-			&VerifyParams{
-				Type:  "signup",
-				Token: u.ConfirmationToken,
-			},
-		},
-		{
-			"Verify banned user on invite",
-			&VerifyParams{
-				Type:  "invite",
-				Token: u.ConfirmationToken,
-			},
-		},
-		{
-			"Verify banned phone user on sms",
-			&VerifyParams{
-				Type:  "sms",
-				Token: u.ConfirmationToken,
-				Phone: u.GetPhone(),
-			},
-		},
-		{
-			"Verify banned user on recover",
-			&VerifyParams{
-				Type:  "recovery",
-				Token: u.RecoveryToken,
-			},
-		},
-		{
-			"Verify banned user on magiclink",
-			&VerifyParams{
-				Type:  "magiclink",
-				Token: u.RecoveryToken,
-			},
-		},
-		{
-			"Verify banned user on email change",
-			&VerifyParams{
-				Type:  "email_change",
-				Token: u.EmailChangeTokenCurrent,
-			},
-		},
-	}
-
-	for _, c := range cases {
-		ts.Run(c.desc, func() {
-			var buffer bytes.Buffer
-			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.payload))
-
-			req := httptest.NewRequest(http.MethodPost, "http://localhost/verify", &buffer)
-			req.Header.Set("Content-Type", "application/json")
-
-			w := httptest.NewRecorder()
-			ts.API.handler.ServeHTTP(w, req)
-			assert.Equal(ts.T(), http.StatusSeeOther, w.Code)
-
-			url, err := w.Result().Location()
-			require.NoError(ts.T(), err)
-			assert.Equal(ts.T(), "error=unauthorized_client&error_code=401&error_description=Error+confirming+user", url.Fragment)
-		})
-	}
-}
diff --git a/hack/test.env b/hack/test.env
index fb1c56c3..9b5d9b89 100644
--- a/hack/test.env
+++ b/hack/test.env
@@ -69,6 +69,10 @@ GOTRUE_EXTERNAL_TWITTER_ENABLED=true
 GOTRUE_EXTERNAL_TWITTER_CLIENT_ID=testclientid
 GOTRUE_EXTERNAL_TWITTER_SECRET=testsecret
 GOTRUE_EXTERNAL_TWITTER_REDIRECT_URI=https://identity.services.netlify.com/callback
+GOTRUE_EXTERNAL_ZOOM_ENABLED=true
+GOTRUE_EXTERNAL_ZOOM_CLIENT_ID=testclientid
+GOTRUE_EXTERNAL_ZOOM_SECRET=testsecret
+GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI=https://identity.services.netlify.com/callback
 GOTRUE_EXTERNAL_SAML_ENABLED=true
 GOTRUE_EXTERNAL_SAML_METADATA_URL=
 GOTRUE_EXTERNAL_SAML_API_BASE=http://localhost