Repository: supabase/gotrue
Pull request #402 (2022-02-27 05:40:16)

feature: add new WorkOS provider
## What kind of change does this PR introduce?

Feature.

## What is the current behavior?

No provider for [WorkOS](https://workos.com/).

## What is the new behavior?

A new provider for WorkOS is added.

## Additional context

Based on initial work done by @HarryET in https://github.com/supabase/gotrue/pull/269 and @J0 in https://github.com/supabase/gotrue/pull/346.

Points to note:
- The WorkOS API differs from typical OAuth2 APIs as it requires additional query parameters to be supplied (see https://workos.com/docs/reference/sso/authorize/get). Hence, additional query parameters must be passed to GoTrue's `/authorize` route as well to be passed through. This required a tweak to `Api.Provider` to accept another argument of type `*url.Values`, which can be used to pass through query parameters for any providers requiring them.
- WorkOS returns user data in a single API call along with the access token, which avoids the need to send a separate request. Since the data is stored in a `map[string]interface{}`, I added a module (https://github.com/mitchellh/mapstructure) which helps to automatically convert it into a struct, instead of having to do the JSON serialize/deserialize dance.
- I made some tweaks to the `assertAuthorizationSuccess` test utility, which are explained in detail in the commit message here: https://github.com/supabase/gotrue/commit/20c18c34ea47ff1c0ac6a6027c61b1fcaaee7435.


Patch:
diff --git a/README.md b/README.md
index 5949d819..cf0980ad 100644
--- a/README.md
+++ b/README.md
@@ -193,7 +193,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 +514,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 +923,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/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/conf/configuration.go b/conf/configuration.go
index ee9bc0a5..a224d89a 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -95,6 +95,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=


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/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