Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Zitadel Provider #1836

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
4 changes: 4 additions & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/api/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
182 changes: 182 additions & 0 deletions internal/api/external_zitadel_test.go
Original file line number Diff line number Diff line change
@@ -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", "")
}
98 changes: 98 additions & 0 deletions internal/api/provider/zitadel.go
Original file line number Diff line number Diff line change
@@ -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

}
2 changes: 2 additions & 0 deletions internal/api/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions internal/api/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

}
Expand Down
1 change: 1 addition & 0 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down