diff --git a/README.md b/README.md index 6e9fb19de..e341dc500 100644 --- a/README.md +++ b/README.md @@ -428,7 +428,7 @@ The default group to assign all new users to. ### External Authentication Providers -We support `apple`, `azure`, `bitbucket`, `discord`, `facebook`, `figma`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication. +We support `apple`, `azure`, `beyondidentity`, `bitbucket`, `discord`, `facebook`, `figma`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `spotify`, `slack`, `twitch`, `twitter` and `workos` for external authentication. Use the names as the keys underneath `external` to configure each separately. @@ -459,7 +459,10 @@ The URI a OAuth2 provider will redirect to with the `code` and `state` values. `EXTERNAL_X_URL` - `string` -The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab` and `keycloak`. For `gitlab` it defaults to `https://gitlab.com`. For `keycloak` you need to set this to your instance, for example: `https://keycloak.example.com/realms/myrealm` +The base URL used for constructing the URLs to request authorization and access tokens. Used by `gitlab`, `keycloak`, and `beyondidentity`. +For `gitlab` it defaults to `https://gitlab.com`. +For `keycloak` you need to set this to your instance, for example: `https://keycloak.example.com/realms/myrealm`. +For `beyondidentity`, set the Issuer from the beyond identity admin console. This will look like `https://auth-{region}.beyondidentity.com/authenticator/v1/tenants/{tenantId}/realms/{realmId}/applications/{applicationId}`. #### Apple OAuth @@ -748,6 +751,7 @@ Returns the publicly available settings for this gotrue instance. "external": { "apple": true, "azure": true, + "beyondidentity": true, "bitbucket": true, "discord": true, "facebook": true, @@ -1197,7 +1201,7 @@ Get access_token from external oauth provider query params: ``` -provider=apple | azure | bitbucket | discord | facebook | figma | github | gitlab | google | keycloak | linkedin | notion | slack | spotify | twitch | twitter | workos +provider=apple | azure | beyondidentity | bitbucket | discord | facebook | figma | github | gitlab | google | keycloak | linkedin | notion | slack | spotify | twitch | twitter | workos scopes= ``` diff --git a/example.env b/example.env index c18f85c01..f5d8e4170 100644 --- a/example.env +++ b/example.env @@ -66,6 +66,13 @@ GOTRUE_EXTERNAL_AZURE_CLIENT_ID="" GOTRUE_EXTERNAL_AZURE_SECRET="" GOTRUE_EXTERNAL_AZURE_REDIRECT_URI="https://localhost:9999/callback" +# Beyond Identity OAuth config +GOTRUE_EXTERNAL_BEYONDIDENTITY_ENABLED="false" +GOTRUE_EXTERNAL_BEYONDIDENTITY_CLIENT_ID="" +GOTRUE_EXTERNAL_BEYONDIDENTITY_SECRET="" +GOTRUE_EXTERNAL_BEYONDIDENTITY_REDIRECT_URI="http://localhost:9999/callback" +GOTRUE_EXTERNAL_BEYONDIDENTITY_URL="https://auth-{region}.beyondidentity.com/authenticator/v1/tenants/{tenantId}/realms/{realmId}/applications/{applicationId}" + # Bitbucket OAuth config GOTRUE_EXTERNAL_BITBUCKET_ENABLED="false" GOTRUE_EXTERNAL_BITBUCKET_CLIENT_ID="" diff --git a/hack/test.env b/hack/test.env index 1789540e6..dc556e8b3 100644 --- a/hack/test.env +++ b/hack/test.env @@ -23,6 +23,11 @@ GOTRUE_EXTERNAL_AZURE_ENABLED=true GOTRUE_EXTERNAL_AZURE_CLIENT_ID=testclientid GOTRUE_EXTERNAL_AZURE_SECRET=testsecret GOTRUE_EXTERNAL_AZURE_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_BEYONDIDENTITY_ENABLED=true +GOTRUE_EXTERNAL_BEYONDIDENTITY_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_BEYONDIDENTITY_SECRET=testsecret +GOTRUE_EXTERNAL_BEYONDIDENTITY_REDIRECT_URI=https://identity.services.netlify.com/callback +GOTRUE_EXTERNAL_BEYONDIDENTITY_URL=https://auth-us.beyondidentity.com/authenticator/v1/tenants/tenantId/realms/realmId/applications/applicationId GOTRUE_EXTERNAL_BITBUCKET_ENABLED=true GOTRUE_EXTERNAL_BITBUCKET_CLIENT_ID=testclientid GOTRUE_EXTERNAL_BITBUCKET_SECRET=testsecret diff --git a/internal/api/external.go b/internal/api/external.go index 1f13983b2..42840072e 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -515,6 +515,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewAppleProvider(ctx, config.External.Apple) case "azure": return provider.NewAzureProvider(config.External.Azure, scopes) + case "beyondidentity": + return provider.NewBeyondIdentityProvider(config.External.BeyondIdentity, scopes) case "bitbucket": return provider.NewBitbucketProvider(config.External.Bitbucket) case "discord": diff --git a/internal/api/external_beyondidentity_test.go b/internal/api/external_beyondidentity_test.go new file mode 100644 index 000000000..084771026 --- /dev/null +++ b/internal/api/external_beyondidentity_test.go @@ -0,0 +1,166 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt" +) + +const ( + beyondIdentityUser string = `{"sub": "beyondidentitytestid", "email": "beyondidentity@example.com", "name": "beyondidentity@example.com", "preferred_username": ""}` +) + +func (ts *ExternalTestSuite) TestSignupExternalBeyondIdentity() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=beyondidentity", 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.BeyondIdentity.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.BeyondIdentity.ClientID, []string{q.Get("client_id")}) + ts.Equal("code", q.Get("response_type")) + ts.Equal("email openid", q.Get("scope")) + + 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("beyondidentity", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func BeyondIdentityTestSignupSetup(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 "/token": + *tokenCount++ + ts.Equal(code, r.FormValue("code")) + ts.Equal("authorization_code", r.FormValue("grant_type")) + ts.Equal(ts.Config.External.BeyondIdentity.RedirectURI, r.FormValue("redirect_uri")) + + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"beyondidentity_token","expires_in":100000}`) + case "/userinfo": + *userCount++ + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, user) + default: + w.WriteHeader(500) + ts.Fail("unknown beyondidentity oauth call %s", r.URL.Path) + } + })) + + ts.Config.External.BeyondIdentity.URL = server.URL + + return server +} + +func (ts *ExternalTestSuite) TestSignupExternalBeyondIdentityWithoutURLSetup() { + ts.createUser("beyondidentitytestid", "beyondidentity@example.com", "", "", "") + tokenCount, userCount := 0, 0 + code := "authcode" + server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser) + ts.Config.External.BeyondIdentity.URL = "" + defer server.Close() + + w := performAuthorizationRequest(ts, "beyondidentity", code) + ts.Equal(w.Code, http.StatusBadRequest) +} + +func (ts *ExternalTestSuite) TestSignupExternalBeyondIdentity_AuthorizationCode() { + ts.Config.DisableSignup = false + tokenCount, userCount := 0, 0 + code := "authcode" + server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser) + defer server.Close() + + u := performAuthorization(ts, "beyondidentity", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "beyondidentity@example.com", "beyondidentity@example.com", "beyondidentitytestid", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalBeyondIdentityDisableSignupErrorWhenNoUser() { + ts.Config.DisableSignup = true + tokenCount, userCount := 0, 0 + code := "authcode" + server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser) + defer server.Close() + + u := performAuthorization(ts, "beyondidentity", code, "") + + assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "beyondidentity@example.com") +} + +func (ts *ExternalTestSuite) TestSignupExternalBeyondIdentityDisableSignupSuccessWithPrimaryEmail() { + ts.Config.DisableSignup = true + + ts.createUser("beyondidentitytestid", "beyondidentity@example.com", "beyondidentity@example.com", "avatar", "") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser) + defer server.Close() + + u := performAuthorization(ts, "beyondidentity", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "beyondidentity@example.com", "beyondidentity@example.com", "beyondidentitytestid", "avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalBeyondIdentitySuccessWhenMatchingToken() { + ts.createUser("beyondidentitytestid", "beyondidentity@example.com", "beyondidentity@example.com", "avatar", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser) + defer server.Close() + + u := performAuthorization(ts, "beyondidentity", code, "invite_token") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "beyondidentity@example.com", "beyondidentity@example.com", "beyondidentitytestid", "avatar") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalBeyondIdentityErrorWhenNoMatchingToken() { + tokenCount, userCount := 0, 0 + code := "authcode" + beyondIdentityUser := `{"sub":"beyondidentitytestid","email":"beyondidentity@example.com"}` + server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "beyondidentity", "invite_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalBeyondIdentityErrorWhenWrongToken() { + ts.createUser("beyondidentitytestid", "beyondidentity@example.com", "beyondidentity@example.com", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + beyondIdentityUser := `{"sub":"beyondidentitytestid","email":"beyondidentity@example.com"}` + server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "beyondidentity", "wrong_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalBeyondIdentityErrorWhenEmailDoesntMatch() { + ts.createUser("beyondidentitytestid", "beyondidentity@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + beyondIdentityUser := `{"sub":"beyondidentitytestid","email":"other@example.com"}` + server := BeyondIdentityTestSignupSetup(ts, &tokenCount, &userCount, code, beyondIdentityUser) + defer server.Close() + + u := performAuthorization(ts, "beyondidentity", code, "invite_token") + + assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "") +} diff --git a/internal/api/provider/beyondidentity.go b/internal/api/provider/beyondidentity.go new file mode 100644 index 000000000..5a73e0cc5 --- /dev/null +++ b/internal/api/provider/beyondidentity.go @@ -0,0 +1,103 @@ +package provider + +import ( + "context" + "errors" + "strings" + + "github.com/supabase/gotrue/internal/conf" + "golang.org/x/oauth2" +) + +// Beyond Identity +type beyondIdentityProvider struct { + *oauth2.Config + Host string +} + +type beyondIdentityUser struct { + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` +} + +// NewBeyondIdentityProvider creates a BeyondIdentity account provider. +func NewBeyondIdentityProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + oauthScopes := []string{ + "email", + "openid", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + if ext.URL == "" { + return nil, errors.New("unable to find Issuer URL for the BeyondIdentity provider") + } + + extURLlen := len(ext.URL) + if ext.URL[extURLlen-1] == '/' { + ext.URL = ext.URL[:extURLlen-1] + } + + return &beyondIdentityProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: ext.URL + "/authorize", + TokenURL: ext.URL + "/token", + }, + RedirectURL: ext.RedirectURI, + Scopes: oauthScopes, + }, + Host: ext.URL, + }, nil +} + +func (g beyondIdentityProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code) +} + +func (g beyondIdentityProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var u beyondIdentityUser + + if err := makeRequest(ctx, tok, g.Config, g.Host+"/userinfo", &u); err != nil { + return nil, err + } + + var name string + if u.Name != "" { + name = u.Name + } else { + name = u.Email + } + + return &UserProvidedData{ + Metadata: &Claims{ + Issuer: g.Host, + Subject: u.Sub, + Name: name, + PreferredUsername: u.PreferredUsername, + Email: u.Email, + EmailVerified: true, // if email is returned, the email is verified by beyondidentity already + + // To be deprecated + FullName: u.Name, + ProviderId: u.Sub, + }, + Emails: []Email{{ + Email: u.Email, + Verified: true, + Primary: true, + }}, + }, nil + +} diff --git a/internal/api/settings.go b/internal/api/settings.go index a24690eb3..3c7cbbf50 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -3,27 +3,28 @@ package api import "net/http" type ProviderSettings struct { - Apple bool `json:"apple"` - Azure bool `json:"azure"` - Bitbucket bool `json:"bitbucket"` - Discord bool `json:"discord"` - Facebook bool `json:"facebook"` - Figma bool `json:"figma"` - GitHub bool `json:"github"` - GitLab bool `json:"gitlab"` - Google bool `json:"google"` - Keycloak bool `json:"keycloak"` - Kakao bool `json:"kakao"` - Linkedin bool `json:"linkedin"` - 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"` - Phone bool `json:"phone"` - Zoom bool `json:"zoom"` + Apple bool `json:"apple"` + Azure bool `json:"azure"` + BeyondIdentity bool `json:"beyondidentity"` + Bitbucket bool `json:"bitbucket"` + Discord bool `json:"discord"` + Facebook bool `json:"facebook"` + Figma bool `json:"figma"` + GitHub bool `json:"github"` + GitLab bool `json:"gitlab"` + Google bool `json:"google"` + Keycloak bool `json:"keycloak"` + Kakao bool `json:"kakao"` + Linkedin bool `json:"linkedin"` + 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"` + Phone bool `json:"phone"` + Zoom bool `json:"zoom"` } type Settings struct { @@ -41,27 +42,28 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { return sendJSON(w, http.StatusOK, &Settings{ ExternalProviders: ProviderSettings{ - Apple: config.External.Apple.Enabled, - Azure: config.External.Azure.Enabled, - Bitbucket: config.External.Bitbucket.Enabled, - Discord: config.External.Discord.Enabled, - Facebook: config.External.Facebook.Enabled, - Figma: config.External.Figma.Enabled, - GitHub: config.External.Github.Enabled, - GitLab: config.External.Gitlab.Enabled, - Google: config.External.Google.Enabled, - Kakao: config.External.Kakao.Enabled, - Keycloak: config.External.Keycloak.Enabled, - Linkedin: config.External.Linkedin.Enabled, - Notion: config.External.Notion.Enabled, - Spotify: config.External.Spotify.Enabled, - 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, - Zoom: config.External.Zoom.Enabled, + Apple: config.External.Apple.Enabled, + Azure: config.External.Azure.Enabled, + BeyondIdentity: config.External.BeyondIdentity.Enabled, + Bitbucket: config.External.Bitbucket.Enabled, + Discord: config.External.Discord.Enabled, + Facebook: config.External.Facebook.Enabled, + Figma: config.External.Figma.Enabled, + GitHub: config.External.Github.Enabled, + GitLab: config.External.Gitlab.Enabled, + Google: config.External.Google.Enabled, + Kakao: config.External.Kakao.Enabled, + Keycloak: config.External.Keycloak.Enabled, + Linkedin: config.External.Linkedin.Enabled, + Notion: config.External.Notion.Enabled, + Spotify: config.External.Spotify.Enabled, + 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, + Zoom: config.External.Zoom.Enabled, }, DisableSignup: config.DisableSignup, diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index b066f4348..d0af6f3da 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -29,6 +29,7 @@ func TestSettings_DefaultProviders(t *testing.T) { require.False(t, p.Phone) require.True(t, p.Email) require.True(t, p.Azure) + require.True(t, p.BeyondIdentity) require.True(t, p.Bitbucket) require.True(t, p.Discord) require.True(t, p.Facebook) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 3832e65a3..a7c3ef0aa 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -167,6 +167,7 @@ type EmailContentConfiguration struct { type ProviderConfiguration struct { Apple OAuthProviderConfiguration `json:"apple"` Azure OAuthProviderConfiguration `json:"azure"` + BeyondIdentity OAuthProviderConfiguration `json:"beyondidentity"` Bitbucket OAuthProviderConfiguration `json:"bitbucket"` Discord OAuthProviderConfiguration `json:"discord"` Facebook OAuthProviderConfiguration `json:"facebook"`