diff --git a/example.env b/example.env index e645c96e9..6e75ee364 100644 --- a/example.env +++ b/example.env @@ -162,6 +162,12 @@ GOTRUE_EXTERNAL_WORKOS_CLIENT_ID="" GOTRUE_EXTERNAL_WORKOS_SECRET="" GOTRUE_EXTERNAL_WORKOS_REDIRECT_URI="http://localhost:9999/callback" +# Zitadel OAuth config +GOTRUE_EXTERNAL_ZITADEL_ENABLED="false" +GOTRUE_EXTERNAL_ZITADEL_CLIENT_ID="" +GOTRUE_EXTERNAL_ZITADEL_SECRET="" +GOTRUE_EXTERNAL_ZITADEL_REDIRECT_URI="http://localhost:9999/callback" + # Zoom OAuth config GOTRUE_EXTERNAL_ZOOM_ENABLED="false" GOTRUE_EXTERNAL_ZOOM_CLIENT_ID="" diff --git a/hack/test.env b/hack/test.env index 35e4b61c8..3e4ecfb4e 100644 --- a/hack/test.env +++ b/hack/test.env @@ -100,6 +100,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_ZITADEL_ENABLED=true +GOTRUE_EXTERNAL_ZITADEL_CLIENT_ID=testclientid +GOTRUE_EXTERNAL_ZITADEL_SECRET=testsecret +GOTRUE_EXTERNAL_ZITADEL_REDIRECT_URI=https://identity.services.netlify.com/callback GOTRUE_EXTERNAL_ZOOM_ENABLED=true GOTRUE_EXTERNAL_ZOOM_CLIENT_ID=testclientid GOTRUE_EXTERNAL_ZOOM_SECRET=testsecret diff --git a/internal/api/external.go b/internal/api/external.go index 2eff891ef..870da254f 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -581,6 +581,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide return provider.NewVercelMarketplaceProvider(config.External.VercelMarketplace, scopes) case "workos": return provider.NewWorkOSProvider(config.External.WorkOS) + case "zitadel": + return provider.NewZitadelProvider(config.External.Zitadel, scopes) case "zoom": return provider.NewZoomProvider(config.External.Zoom) default: diff --git a/internal/api/external_zitadel_test.go b/internal/api/external_zitadel_test.go new file mode 100644 index 000000000..99cac0ae0 --- /dev/null +++ b/internal/api/external_zitadel_test.go @@ -0,0 +1,182 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt/v5" +) + +const ( + zidadelUser string = `{"sub": "zidadeltestid", "name": "Zidadel Test", "email": "zidadel@example.com", "preferred_username": "zidadel", "email_verified": true}` + zidadelUserNoEmail string = `{"sub": "zidadeltestid", "name": "Zidadel Test", "preferred_username": "zidadel", "email_verified": false}` +) + +func (ts *ExternalTestSuite) TestSignupExternalZidadel() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=zidadel", 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.Zidadel.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.Zidadel.ClientID, []string{q.Get("client_id")}) + ts.Equal("code", q.Get("response_type")) + ts.Equal("profile email", q.Get("scope")) + + claims := ExternalProviderClaims{} + p := jwt.NewParser(jwt.WithValidMethods([]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("zidadel", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func ZidadelTestSignupSetup(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/v2/token": + *tokenCount++ + ts.Equal(code, r.FormValue("code")) + ts.Equal("authorization_code", r.FormValue("grant_type")) + ts.Equal(ts.Config.External.Zidadel.RedirectURI, r.FormValue("redirect_uri")) + + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"zidadel_token","expires_in":100000}`) + case "/oidc/v1/userinfo": + *userCount++ + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, user) + default: + w.WriteHeader(500) + ts.Fail("unknown zidadel oauth call %s", r.URL.Path) + } + })) + + ts.Config.External.Zidadel.URL = server.URL + + return server +} + +func (ts *ExternalTestSuite) TestSignupExternalZidadelWithoutURLSetup() { + ts.createUser("zidadeltestid", "zidadel@example.com", "Zidadel Test", "", "") + tokenCount, userCount := 0, 0 + code := "authcode" + server := ZidadelTestSignupSetup(ts, &tokenCount, &userCount, code, zidadelUser) + ts.Config.External.Zidadel.URL = "" + defer server.Close() + + w := performAuthorizationRequest(ts, "zidadel", code) + ts.Equal(w.Code, http.StatusBadRequest) +} + +func (ts *ExternalTestSuite) TestSignupExternalZidadel_AuthorizationCode() { + ts.Config.DisableSignup = false + ts.createUser("zidadeltestid", "zidadel@example.com", "Zidadel Test", "", "") + tokenCount, userCount := 0, 0 + code := "authcode" + server := ZidadelTestSignupSetup(ts, &tokenCount, &userCount, code, zidadelUser) + defer server.Close() + + u := performAuthorization(ts, "zidadel", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "zidadel@example.com", "Zidadel Test", "zidadeltestid", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalZidadelDisableSignupErrorWhenNoUser() { + ts.Config.DisableSignup = true + tokenCount, userCount := 0, 0 + code := "authcode" + server := ZidadelTestSignupSetup(ts, &tokenCount, &userCount, code, zidadelUser) + defer server.Close() + + u := performAuthorization(ts, "zidadel", code, "") + + assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "zidadel@example.com") +} + +func (ts *ExternalTestSuite) TestSignupExternalZidadelDisableSignupErrorWhenNoEmail() { + ts.Config.DisableSignup = true + tokenCount, userCount := 0, 0 + code := "authcode" + server := ZidadelTestSignupSetup(ts, &tokenCount, &userCount, code, zidadelUserNoEmail) + defer server.Close() + + u := performAuthorization(ts, "zidadel", code, "") + + assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "zidadel@example.com") + +} + +func (ts *ExternalTestSuite) TestSignupExternalZidadelDisableSignupSuccessWithPrimaryEmail() { + ts.Config.DisableSignup = true + + ts.createUser("zidadeltestid", "zidadel@example.com", "Zidadel Test", "", "") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := ZidadelTestSignupSetup(ts, &tokenCount, &userCount, code, zidadelUser) + defer server.Close() + + u := performAuthorization(ts, "zidadel", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "zidadel@example.com", "Zidadel Test", "zidadeltestid", "") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalZidadelSuccessWhenMatchingToken() { + // name and avatar should be populated from Zidadel API + ts.createUser("zidadeltestid", "zidadel@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := ZidadelTestSignupSetup(ts, &tokenCount, &userCount, code, zidadelUser) + defer server.Close() + + u := performAuthorization(ts, "zidadel", code, "invite_token") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "zidadel@example.com", "Zidadel Test", "zidadeltestid", "") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalZidadelErrorWhenNoMatchingToken() { + tokenCount, userCount := 0, 0 + code := "authcode" + zidadelUser := `{"name":"Zidadel Test","avatar":{"href":"http://example.com/avatar"}}` + server := ZidadelTestSignupSetup(ts, &tokenCount, &userCount, code, zidadelUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "zidadel", "invite_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalZidadelErrorWhenWrongToken() { + ts.createUser("zidadeltestid", "zidadel@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + zidadelUser := `{"name":"Zidadel Test","avatar":{"href":"http://example.com/avatar"}}` + server := ZidadelTestSignupSetup(ts, &tokenCount, &userCount, code, zidadelUser) + defer server.Close() + + w := performAuthorizationRequest(ts, "zidadel", "wrong_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalZidadelErrorWhenEmailDoesntMatch() { + ts.createUser("zidadeltestid", "zidadel@example.com", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + zidadelUser := `{"name":"Zidadel Test", "email":"other@example.com", "avatar":{"href":"http://example.com/avatar"}}` + server := ZidadelTestSignupSetup(ts, &tokenCount, &userCount, code, zidadelUser) + defer server.Close() + + u := performAuthorization(ts, "zidadel", code, "invite_token") + + assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "") +} diff --git a/internal/api/provider/zitadel.go b/internal/api/provider/zitadel.go new file mode 100644 index 000000000..6605a76e1 --- /dev/null +++ b/internal/api/provider/zitadel.go @@ -0,0 +1,98 @@ +package provider + +import ( + "context" + "errors" + "strings" + + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +// Zitadel +type zitadelProvider struct { + *oauth2.Config + Host string +} + +type zitadelUser struct { + Name string `json:"name"` + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` +} + +// NewZitadelProvider creates a Keycloak account provider. +func NewZitadelProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + oauthScopes := []string{ + "profile", + "email", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + if ext.URL == "" { + return nil, errors.New("unable to find URL for the Keycloak provider") + } + + extURLlen := len(ext.URL) + if ext.URL[extURLlen-1] == '/' { + ext.URL = ext.URL[:extURLlen-1] + } + + return &zitadelProvider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: ext.URL + "/oauth/v2/authorize", + TokenURL: ext.URL + "/oauth/v2/token", + }, + RedirectURL: ext.RedirectURI, + Scopes: oauthScopes, + }, + Host: ext.URL, + }, nil +} + +func (g zitadelProvider) GetOAuthToken(code string) (*oauth2.Token, error) { + return g.Exchange(context.Background(), code) +} + +func (g zitadelProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var u zitadelUser + + if err := makeRequest(ctx, tok, g.Config, g.Host+"/oidc/v1/userinfo", &u); err != nil { + return nil, err + } + + data := &UserProvidedData{} + if u.Email != "" { + data.Emails = []Email{{ + Email: u.Email, + Verified: u.EmailVerified, + Primary: true, + }} + } + + data.Metadata = &Claims{ + Issuer: g.Host, + Subject: u.Sub, + Name: u.Name, + Email: u.Email, + EmailVerified: u.EmailVerified, + + // To be deprecated + FullName: u.Name, + ProviderId: u.Sub, + } + + return data, nil + +} diff --git a/internal/api/settings.go b/internal/api/settings.go index bc2f38692..27d948dd5 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -27,6 +27,7 @@ type ProviderSettings struct { Twitter bool `json:"twitter"` Email bool `json:"email"` Phone bool `json:"phone"` + Zitadel bool `json:"zitadel"` Zoom bool `json:"zoom"` } @@ -68,6 +69,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error { WorkOS: config.External.WorkOS.Enabled, Email: config.External.Email.Enabled, Phone: config.External.Phone.Enabled, + Zitadel: config.External.Zitadel.Enabled, Zoom: config.External.Zoom.Enabled, }, DisableSignup: config.DisableSignup, diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index 767bcf784..d3c6c8c26 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -45,6 +45,7 @@ func TestSettings_DefaultProviders(t *testing.T) { require.True(t, p.GitLab) require.True(t, p.Twitch) require.True(t, p.WorkOS) + require.True(t, p.Zitadel) require.True(t, p.Zoom) } diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 5d5060523..7007f7726 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -334,6 +334,7 @@ type ProviderConfiguration struct { WorkOS OAuthProviderConfiguration `json:"workos"` Email EmailProviderConfiguration `json:"email"` Phone PhoneProviderConfiguration `json:"phone"` + Zitadel OAuthProviderConfiguration `json:"zitadel"` Zoom OAuthProviderConfiguration `json:"zoom"` IosBundleId string `json:"ios_bundle_id" split_words:"true"` RedirectURL string `json:"redirect_url"`