Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
07fdc5d
feat: initial workos implementation
HarryET Nov 13, 2021
7f93e2f
Fix trailing spaces in WorkOS name
HarryET Nov 15, 2021
79381df
fix: correct provider names
Jan 17, 2022
2e86364
chore: apply latest commits
Jan 25, 2022
dcd9b67
chore: run gofmt
Jan 25, 2022
405eebc
chore: strip https on workos link
Feb 4, 2022
d5fcc82
ExternalProviderRedirect: pass query parameters into `Provider` function
bnjmnt4n Feb 25, 2022
c15ea63
Tweak `example.env`
bnjmnt4n Feb 25, 2022
11ffa84
WorkOS provider: construct custom authorization URL from request quer…
bnjmnt4n Feb 25, 2022
81dabf5
Go modules: add `github.com/mitchellh/mapstructure`
bnjmnt4n Feb 25, 2022
760b246
WorkOS provider: obtain user data from response from authorization en…
bnjmnt4n Feb 27, 2022
35e5183
WorkOS provider: return error when returned profile has no email
bnjmnt4n Feb 27, 2022
114d878
WorkOS provider: rename `provider` query parameter
bnjmnt4n Feb 26, 2022
34068bc
WorkOS provider: add more comprehensive tests
bnjmnt4n Feb 26, 2022
20c18c3
Tests: tweak `assertAuthorizationSuccess` to accept empty strings for…
bnjmnt4n Feb 26, 2022
1cddd51
WorkOS provider: do not mark emails as verified
bnjmnt4n Feb 28, 2022
638b5d0
WorkOS provider: fix tests by enabling autoconfirm
bnjmnt4n Feb 28, 2022
bcb1d5a
Revert "WorkOS provider: fix tests by enabling autoconfirm"
bnjmnt4n Mar 2, 2022
6807ae5
Revert "WorkOS provider: do not mark emails as verified"
bnjmnt4n Mar 2, 2022
6fce185
WorkOS provider: add `connection_id` and `organization_id` custom claims
bnjmnt4n Mar 4, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)>
```

Expand Down
15 changes: 9 additions & 6 deletions api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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":
Expand Down
11 changes: 5 additions & 6 deletions api/external_azure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion api/external_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
6 changes: 5 additions & 1 deletion api/external_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
225 changes: 225 additions & 0 deletions api/external_workos_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
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
// Enable autoconfirm since emails from WorkOS are not verified.
ts.Config.Mailer.Autoconfirm = true

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
// Enable autoconfirm since emails from WorkOS are not verified.
ts.Config.Mailer.Autoconfirm = 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", "")
}
Loading