diff --git a/management/server/idp/idp.go b/management/server/idp/idp.go index f29426608..c191f45fe 100644 --- a/management/server/idp/idp.go +++ b/management/server/idp/idp.go @@ -23,8 +23,9 @@ type Manager interface { type Config struct { ManagerType string Auth0ClientCredentials Auth0ClientConfig - KeycloakClientCredentials KeycloakClientConfig AzureClientCredentials AzureClientConfig + KeycloakClientCredentials KeycloakClientConfig + ZitadelClientCredentials ZitadelClientConfig } // ManagerCredentials interface that authenticates using the credential of each type of idp @@ -78,6 +79,8 @@ func NewManager(config Config, appMetrics telemetry.AppMetrics) (Manager, error) return NewAzureManager(config.AzureClientCredentials, appMetrics) case "keycloak": return NewKeycloakManager(config.KeycloakClientCredentials, appMetrics) + case "zitadel": + return NewZitadelManager(config.ZitadelClientCredentials, appMetrics) default: return nil, fmt.Errorf("invalid manager type: %s", config.ManagerType) } diff --git a/management/server/idp/zitadel.go b/management/server/idp/zitadel.go new file mode 100644 index 000000000..b5ff2f914 --- /dev/null +++ b/management/server/idp/zitadel.go @@ -0,0 +1,617 @@ +package idp + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt" + "github.com/netbirdio/netbird/management/server/telemetry" + log "github.com/sirupsen/logrus" +) + +// ZitadelManager zitadel manager client instance. +type ZitadelManager struct { + managementEndpoint string + httpClient ManagerHTTPClient + credentials ManagerCredentials + helper ManagerHelper + appMetrics telemetry.AppMetrics +} + +// ZitadelClientConfig zitadel manager client configurations. +type ZitadelClientConfig struct { + ClientID string + ClientSecret string + GrantType string + TokenEndpoint string + ManagementEndpoint string +} + +// ZitadelCredentials zitadel authentication information. +type ZitadelCredentials struct { + clientConfig ZitadelClientConfig + helper ManagerHelper + httpClient ManagerHTTPClient + jwtToken JWTToken + mux sync.Mutex + appMetrics telemetry.AppMetrics +} + +// zitadelEmail specifies details of a user email. +type zitadelEmail struct { + Email string `json:"email"` + IsEmailVerified bool `json:"isEmailVerified"` +} + +// zitadelUserInfo specifies user information. +type zitadelUserInfo struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + DisplayName string `json:"displayName"` +} + +// zitadelUser specifies profile details for user account. +type zitadelUser struct { + UserName string `json:"userName,omitempty"` + Profile zitadelUserInfo `json:"profile"` + Email zitadelEmail `json:"email"` +} + +type zitadelAttributes map[string][]map[string]any + +// zitadelMetadata holds additional user data. +type zitadelMetadata struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// zitadelProfile represents an zitadel user profile response. +type zitadelProfile struct { + ID string `json:"id"` + State string `json:"state"` + UserName string `json:"userName"` + PreferredLoginName string `json:"preferredLoginName"` + LoginNames []string `json:"loginNames"` + Human *zitadelUser `json:"human"` + Metadata []zitadelMetadata +} + +// NewZitadelManager creates a new instance of the ZitadelManager. +func NewZitadelManager(config ZitadelClientConfig, appMetrics telemetry.AppMetrics) (*ZitadelManager, error) { + httpTransport := http.DefaultTransport.(*http.Transport).Clone() + httpTransport.MaxIdleConns = 5 + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: httpTransport, + } + + helper := JsonParser{} + + if config.ClientID == "" { + return nil, fmt.Errorf("zitadel IdP configuration is incomplete, clientID is missing") + } + + if config.ClientSecret == "" { + return nil, fmt.Errorf("zitadel IdP configuration is incomplete, ClientSecret is missing") + } + + if config.TokenEndpoint == "" { + return nil, fmt.Errorf("zitadel IdP configuration is incomplete, TokenEndpoint is missing") + } + + if config.ManagementEndpoint == "" { + return nil, fmt.Errorf("zitadel IdP configuration is incomplete, ManagementEndpoint is missing") + } + + if config.GrantType != "client_credentials" { + return nil, fmt.Errorf("zitadel idp configuration failed. Grant Type should be client_credentials") + } + + credentials := &ZitadelCredentials{ + clientConfig: config, + httpClient: httpClient, + helper: helper, + appMetrics: appMetrics, + } + + return &ZitadelManager{ + managementEndpoint: config.ManagementEndpoint, + httpClient: httpClient, + credentials: credentials, + helper: helper, + appMetrics: appMetrics, + }, nil +} + +// jwtStillValid returns true if the token still valid and have enough time to be used and get a response from zitadel. +func (zc *ZitadelCredentials) jwtStillValid() bool { + return !zc.jwtToken.expiresInTime.IsZero() && time.Now().Add(5*time.Second).Before(zc.jwtToken.expiresInTime) +} + +// requestJWTToken performs request to get jwt token. +func (zc *ZitadelCredentials) requestJWTToken() (*http.Response, error) { + data := url.Values{} + data.Set("client_id", zc.clientConfig.ClientID) + data.Set("client_secret", zc.clientConfig.ClientSecret) + data.Set("grant_type", zc.clientConfig.GrantType) + data.Set("scope", "urn:zitadel:iam:org:project:id:zitadel:aud") + + payload := strings.NewReader(data.Encode()) + req, err := http.NewRequest(http.MethodPost, zc.clientConfig.TokenEndpoint, payload) + if err != nil { + return nil, err + } + req.Header.Add("content-type", "application/x-www-form-urlencoded") + + log.Debug("requesting new jwt token for zitadel idp manager") + + resp, err := zc.httpClient.Do(req) + if err != nil { + if zc.appMetrics != nil { + zc.appMetrics.IDPMetrics().CountRequestError() + } + + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unable to get zitadel token, statusCode %d", resp.StatusCode) + } + + return resp, nil +} + +// parseRequestJWTResponse parses jwt raw response body and extracts token and expires in seconds. +func (zc *ZitadelCredentials) parseRequestJWTResponse(rawBody io.ReadCloser) (JWTToken, error) { + jwtToken := JWTToken{} + body, err := io.ReadAll(rawBody) + if err != nil { + return jwtToken, err + } + + err = zc.helper.Unmarshal(body, &jwtToken) + if err != nil { + return jwtToken, err + } + + if jwtToken.ExpiresIn == 0 && jwtToken.AccessToken == "" { + return jwtToken, fmt.Errorf("error while reading response body, expires_in: %d and access_token: %s", jwtToken.ExpiresIn, jwtToken.AccessToken) + } + + data, err := jwt.DecodeSegment(strings.Split(jwtToken.AccessToken, ".")[1]) + if err != nil { + return jwtToken, err + } + + // Exp maps into exp from jwt token + var IssuedAt struct{ Exp int64 } + err = zc.helper.Unmarshal(data, &IssuedAt) + if err != nil { + return jwtToken, err + } + jwtToken.expiresInTime = time.Unix(IssuedAt.Exp, 0) + + return jwtToken, nil +} + +// Authenticate retrieves access token to use the Zitadel Management API. +func (zc *ZitadelCredentials) Authenticate() (JWTToken, error) { + zc.mux.Lock() + defer zc.mux.Unlock() + + if zc.appMetrics != nil { + zc.appMetrics.IDPMetrics().CountAuthenticate() + } + + // reuse the token without requesting a new one if it is not expired, + // and if expiry time is sufficient time available to make a request. + if zc.jwtStillValid() { + return zc.jwtToken, nil + } + + resp, err := zc.requestJWTToken() + if err != nil { + return zc.jwtToken, err + } + defer resp.Body.Close() + + jwtToken, err := zc.parseRequestJWTResponse(resp.Body) + if err != nil { + return zc.jwtToken, err + } + + zc.jwtToken = jwtToken + + return zc.jwtToken, nil +} + +// CreateUser creates a new user in zitadel Idp and sends an invite. +func (zm *ZitadelManager) CreateUser(email string, name string, accountID string) (*UserData, error) { + payload, err := buildZitadelCreateUserRequestPayload(email, name) + if err != nil { + return nil, err + } + + body, err := zm.post("users/human/_import", payload) + if err != nil { + return nil, err + } + + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountCreateUser() + } + + var result struct { + UserID string `json:"userId"` + } + err = zm.helper.Unmarshal(body, &result) + if err != nil { + return nil, err + } + + invite := true + appMetadata := AppMetadata{ + WTAccountID: accountID, + WTPendingInvite: &invite, + } + + // Add metadata to new user + err = zm.UpdateUserAppMetadata(result.UserID, appMetadata) + if err != nil { + return nil, err + } + + return zm.GetUserDataByID(result.UserID, appMetadata) +} + +// GetUserByEmail searches users with a given email. +// If no users have been found, this function returns an empty list. +func (zm *ZitadelManager) GetUserByEmail(email string) ([]*UserData, error) { + searchByEmail := zitadelAttributes{ + "queries": { + { + "emailQuery": map[string]any{ + "emailAddress": email, + "method": "TEXT_QUERY_METHOD_EQUALS", + }, + }, + }, + } + payload, err := zm.helper.Marshal(searchByEmail) + if err != nil { + return nil, err + } + + body, err := zm.post("users/_search", string(payload)) + if err != nil { + return nil, err + } + + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountGetUserByEmail() + } + + var profiles struct{ Result []zitadelProfile } + err = zm.helper.Unmarshal(body, &profiles) + if err != nil { + return nil, err + } + + users := make([]*UserData, 0) + for _, profile := range profiles.Result { + metadata, err := zm.getUserMetadata(profile.ID) + if err != nil { + return nil, err + } + profile.Metadata = metadata + + users = append(users, profile.userData()) + } + + return users, nil +} + +// GetUserDataByID requests user data from zitadel via ID. +func (zm *ZitadelManager) GetUserDataByID(userID string, appMetadata AppMetadata) (*UserData, error) { + body, err := zm.get("users/"+userID, nil) + if err != nil { + return nil, err + } + + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountGetUserDataByID() + } + + var profile struct{ User zitadelProfile } + err = zm.helper.Unmarshal(body, &profile) + if err != nil { + return nil, err + } + + metadata, err := zm.getUserMetadata(userID) + if err != nil { + return nil, err + } + profile.User.Metadata = metadata + + return profile.User.userData(), nil +} + +// GetAccount returns all the users for a given profile. +func (zm *ZitadelManager) GetAccount(accountID string) ([]*UserData, error) { + accounts, err := zm.GetAllAccounts() + if err != nil { + return nil, err + } + + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountGetAccount() + } + + return accounts[accountID], nil +} + +// GetAllAccounts gets all registered accounts with corresponding user data. +// It returns a list of users indexed by accountID. +func (zm *ZitadelManager) GetAllAccounts() (map[string][]*UserData, error) { + body, err := zm.post("users/_search", "") + if err != nil { + return nil, err + } + + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountGetAllAccounts() + } + + var profiles struct{ Result []zitadelProfile } + err = zm.helper.Unmarshal(body, &profiles) + if err != nil { + return nil, err + } + + indexedUsers := make(map[string][]*UserData) + for _, profile := range profiles.Result { + // fetch user metadata + metadata, err := zm.getUserMetadata(profile.ID) + if err != nil { + return nil, err + } + profile.Metadata = metadata + + userData := profile.userData() + accountID := userData.AppMetadata.WTAccountID + + if accountID != "" { + if _, ok := indexedUsers[accountID]; !ok { + indexedUsers[accountID] = make([]*UserData, 0) + } + indexedUsers[accountID] = append(indexedUsers[accountID], userData) + } + } + + return indexedUsers, nil +} + +// UpdateUserAppMetadata updates user app metadata based on userID and metadata map. +// Metadata values are base64 encoded. +func (zm *ZitadelManager) UpdateUserAppMetadata(userID string, appMetadata AppMetadata) error { + if appMetadata.WTPendingInvite == nil { + appMetadata.WTPendingInvite = new(bool) + } + pendingInviteBuf := strconv.AppendBool([]byte{}, *appMetadata.WTPendingInvite) + + wtAccountIDValue := base64.StdEncoding.EncodeToString([]byte(appMetadata.WTAccountID)) + wtPendingInviteValue := base64.StdEncoding.EncodeToString(pendingInviteBuf) + + metadata := zitadelAttributes{ + "metadata": { + { + "key": wtAccountID, + "value": wtAccountIDValue, + }, + { + "key": wtPendingInvite, + "value": wtPendingInviteValue, + }, + }, + } + payload, err := zm.helper.Marshal(metadata) + if err != nil { + return err + } + + resource := fmt.Sprintf("users/%s/metadata/_bulk", userID) + _, err = zm.post(resource, string(payload)) + if err != nil { + return err + } + + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountUpdateUserAppMetadata() + } + + return nil +} + +// getUserMetadata requests user metadata from zitadel via ID. +func (zm *ZitadelManager) getUserMetadata(userID string) ([]zitadelMetadata, error) { + resource := fmt.Sprintf("users/%s/metadata/_search", userID) + body, err := zm.post(resource, "") + if err != nil { + return nil, err + } + + var metadata struct{ Result []zitadelMetadata } + err = zm.helper.Unmarshal(body, &metadata) + if err != nil { + return nil, err + } + + return metadata.Result, nil +} + +// post perform Post requests. +func (zm *ZitadelManager) post(resource string, body string) ([]byte, error) { + jwtToken, err := zm.credentials.Authenticate() + if err != nil { + return nil, err + } + + reqURL := fmt.Sprintf("%s/%s", zm.managementEndpoint, resource) + req, err := http.NewRequest(http.MethodPost, reqURL, strings.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken) + req.Header.Add("content-type", "application/json") + + resp, err := zm.httpClient.Do(req) + if err != nil { + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountRequestError() + } + + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountRequestStatusError() + } + + return nil, fmt.Errorf("unable to post %s, statusCode %d", reqURL, resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// get perform Get requests. +func (zm *ZitadelManager) get(resource string, q url.Values) ([]byte, error) { + jwtToken, err := zm.credentials.Authenticate() + if err != nil { + return nil, err + } + + reqURL := fmt.Sprintf("%s/%s?%s", zm.managementEndpoint, resource, q.Encode()) + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return nil, err + } + req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken) + req.Header.Add("content-type", "application/json") + + resp, err := zm.httpClient.Do(req) + if err != nil { + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountRequestError() + } + + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountRequestStatusError() + } + + return nil, fmt.Errorf("unable to get %s, statusCode %d", reqURL, resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// value returns string represented by the base64 string value. +func (zm zitadelMetadata) value() string { + value, err := base64.StdEncoding.DecodeString(zm.Value) + if err != nil { + return "" + } + + return string(value) +} + +// userData construct user data from zitadel profile. +func (zp zitadelProfile) userData() *UserData { + var ( + email string + name string + wtAccountIDValue string + wtPendingInviteValue bool + ) + + for _, metadata := range zp.Metadata { + if metadata.Key == wtAccountID { + wtAccountIDValue = metadata.value() + } + + if metadata.Key == wtPendingInvite { + value, err := strconv.ParseBool(metadata.value()) + if err == nil { + wtPendingInviteValue = value + } + } + } + + // Obtain the email for the human account and the login name, + // for the machine account. + if zp.Human != nil { + email = zp.Human.Email.Email + name = zp.Human.Profile.DisplayName + } else { + if len(zp.LoginNames) > 0 { + email = zp.LoginNames[0] + name = zp.LoginNames[0] + } + } + + return &UserData{ + Email: email, + Name: name, + ID: zp.ID, + AppMetadata: AppMetadata{ + WTAccountID: wtAccountIDValue, + WTPendingInvite: &wtPendingInviteValue, + }, + } +} + +func buildZitadelCreateUserRequestPayload(email string, name string) (string, error) { + var firstName, lastName string + + words := strings.Fields(name) + if n := len(words); n > 0 { + firstName = strings.Join(words[:n-1], " ") + lastName = words[n-1] + } + + req := &zitadelUser{ + UserName: name, + Profile: zitadelUserInfo{ + FirstName: strings.TrimSpace(firstName), + LastName: strings.TrimSpace(lastName), + DisplayName: name, + }, + Email: zitadelEmail{ + Email: email, + IsEmailVerified: false, + }, + } + + str, err := json.Marshal(req) + if err != nil { + return "", err + } + + return string(str), nil +} diff --git a/management/server/idp/zitadel_test.go b/management/server/idp/zitadel_test.go new file mode 100644 index 000000000..9017883fe --- /dev/null +++ b/management/server/idp/zitadel_test.go @@ -0,0 +1,486 @@ +package idp + +import ( + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewZitadelManager(t *testing.T) { + type test struct { + name string + inputConfig ZitadelClientConfig + assertErrFunc require.ErrorAssertionFunc + assertErrFuncMessage string + } + + defaultTestConfig := ZitadelClientConfig{ + ClientID: "client_id", + ClientSecret: "client_secret", + GrantType: "client_credentials", + TokenEndpoint: "http://localhost/oauth/v2/token", + ManagementEndpoint: "http://localhost/management/v1", + } + + testCase1 := test{ + name: "Good Configuration", + inputConfig: defaultTestConfig, + assertErrFunc: require.NoError, + assertErrFuncMessage: "shouldn't return error", + } + + testCase2Config := defaultTestConfig + testCase2Config.ClientID = "" + + testCase2 := test{ + name: "Missing ClientID Configuration", + inputConfig: testCase2Config, + assertErrFunc: require.Error, + assertErrFuncMessage: "should return error when field empty", + } + + testCase5Config := defaultTestConfig + testCase5Config.GrantType = "authorization_code" + + testCase5 := test{ + name: "Wrong GrantType", + inputConfig: testCase5Config, + assertErrFunc: require.Error, + assertErrFuncMessage: "should return error when wrong grant type", + } + + for _, testCase := range []test{testCase1, testCase2, testCase5} { + t.Run(testCase.name, func(t *testing.T) { + _, err := NewZitadelManager(testCase.inputConfig, &telemetry.MockAppMetrics{}) + testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage) + }) + } +} + +type mockZitadelCredentials struct { + jwtToken JWTToken + err error +} + +func (mc *mockZitadelCredentials) Authenticate() (JWTToken, error) { + return mc.jwtToken, mc.err +} + +func TestZitadelRequestJWTToken(t *testing.T) { + + type requestJWTTokenTest struct { + name string + inputCode int + inputRespBody string + helper ManagerHelper + expectedFuncExitErrDiff error + expectedToken string + } + exp := 5 + token := newTestJWT(t, exp) + + requestJWTTokenTesttCase1 := requestJWTTokenTest{ + name: "Good JWT Response", + inputCode: 200, + inputRespBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + helper: JsonParser{}, + expectedToken: token, + } + requestJWTTokenTestCase2 := requestJWTTokenTest{ + name: "Request Bad Status Code", + inputCode: 400, + inputRespBody: "{}", + helper: JsonParser{}, + expectedFuncExitErrDiff: fmt.Errorf("unable to get zitadel token, statusCode 400"), + expectedToken: "", + } + + for _, testCase := range []requestJWTTokenTest{requestJWTTokenTesttCase1, requestJWTTokenTestCase2} { + t.Run(testCase.name, func(t *testing.T) { + + jwtReqClient := mockHTTPClient{ + resBody: testCase.inputRespBody, + code: testCase.inputCode, + } + config := ZitadelClientConfig{} + + creds := ZitadelCredentials{ + clientConfig: config, + httpClient: &jwtReqClient, + helper: testCase.helper, + } + + resp, err := creds.requestJWTToken() + if err != nil { + if testCase.expectedFuncExitErrDiff != nil { + assert.EqualError(t, err, testCase.expectedFuncExitErrDiff.Error(), "errors should be the same") + } else { + t.Fatal(err) + } + } else { + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "unable to read the response body") + + jwtToken := JWTToken{} + err = testCase.helper.Unmarshal(body, &jwtToken) + assert.NoError(t, err, "unable to parse the json input") + + assert.Equalf(t, testCase.expectedToken, jwtToken.AccessToken, "two tokens should be the same") + } + }) + } +} + +func TestZitadelParseRequestJWTResponse(t *testing.T) { + type parseRequestJWTResponseTest struct { + name string + inputRespBody string + helper ManagerHelper + expectedToken string + expectedExpiresIn int + assertErrFunc assert.ErrorAssertionFunc + assertErrFuncMessage string + } + + exp := 100 + token := newTestJWT(t, exp) + + parseRequestJWTResponseTestCase1 := parseRequestJWTResponseTest{ + name: "Parse Good JWT Body", + inputRespBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + helper: JsonParser{}, + expectedToken: token, + expectedExpiresIn: exp, + assertErrFunc: assert.NoError, + assertErrFuncMessage: "no error was expected", + } + parseRequestJWTResponseTestCase2 := parseRequestJWTResponseTest{ + name: "Parse Bad json JWT Body", + inputRespBody: "", + helper: JsonParser{}, + expectedToken: "", + expectedExpiresIn: 0, + assertErrFunc: assert.Error, + assertErrFuncMessage: "json error was expected", + } + + for _, testCase := range []parseRequestJWTResponseTest{parseRequestJWTResponseTestCase1, parseRequestJWTResponseTestCase2} { + t.Run(testCase.name, func(t *testing.T) { + rawBody := io.NopCloser(strings.NewReader(testCase.inputRespBody)) + config := ZitadelClientConfig{} + + creds := ZitadelCredentials{ + clientConfig: config, + helper: testCase.helper, + } + jwtToken, err := creds.parseRequestJWTResponse(rawBody) + testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage) + + assert.Equalf(t, testCase.expectedToken, jwtToken.AccessToken, "two tokens should be the same") + assert.Equalf(t, testCase.expectedExpiresIn, jwtToken.ExpiresIn, "the two expire times should be the same") + }) + } +} + +func TestZitadelJwtStillValid(t *testing.T) { + type jwtStillValidTest struct { + name string + inputTime time.Time + expectedResult bool + message string + } + + jwtStillValidTestCase1 := jwtStillValidTest{ + name: "JWT still valid", + inputTime: time.Now().Add(10 * time.Second), + expectedResult: true, + message: "should be true", + } + jwtStillValidTestCase2 := jwtStillValidTest{ + name: "JWT is invalid", + inputTime: time.Now(), + expectedResult: false, + message: "should be false", + } + + for _, testCase := range []jwtStillValidTest{jwtStillValidTestCase1, jwtStillValidTestCase2} { + t.Run(testCase.name, func(t *testing.T) { + config := ZitadelClientConfig{} + + creds := ZitadelCredentials{ + clientConfig: config, + } + creds.jwtToken.expiresInTime = testCase.inputTime + + assert.Equalf(t, testCase.expectedResult, creds.jwtStillValid(), testCase.message) + }) + } +} + +func TestZitadelAuthenticate(t *testing.T) { + type authenticateTest struct { + name string + inputCode int + inputResBody string + inputExpireToken time.Time + helper ManagerHelper + expectedFuncExitErrDiff error + expectedCode int + expectedToken string + } + exp := 5 + token := newTestJWT(t, exp) + + authenticateTestCase1 := authenticateTest{ + name: "Get Cached token", + inputExpireToken: time.Now().Add(30 * time.Second), + helper: JsonParser{}, + expectedFuncExitErrDiff: nil, + expectedCode: 200, + expectedToken: "", + } + + authenticateTestCase2 := authenticateTest{ + name: "Get Good JWT Response", + inputCode: 200, + inputResBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + helper: JsonParser{}, + expectedCode: 200, + expectedToken: token, + } + + authenticateTestCase3 := authenticateTest{ + name: "Get Bad Status Code", + inputCode: 400, + inputResBody: "{}", + helper: JsonParser{}, + expectedFuncExitErrDiff: fmt.Errorf("unable to get zitadel token, statusCode 400"), + expectedCode: 200, + expectedToken: "", + } + + for _, testCase := range []authenticateTest{authenticateTestCase1, authenticateTestCase2, authenticateTestCase3} { + t.Run(testCase.name, func(t *testing.T) { + + jwtReqClient := mockHTTPClient{ + resBody: testCase.inputResBody, + code: testCase.inputCode, + } + config := ZitadelClientConfig{} + + creds := ZitadelCredentials{ + clientConfig: config, + httpClient: &jwtReqClient, + helper: testCase.helper, + } + creds.jwtToken.expiresInTime = testCase.inputExpireToken + + _, err := creds.Authenticate() + if err != nil { + if testCase.expectedFuncExitErrDiff != nil { + assert.EqualError(t, err, testCase.expectedFuncExitErrDiff.Error(), "errors should be the same") + } else { + t.Fatal(err) + } + } + + assert.Equalf(t, testCase.expectedToken, creds.jwtToken.AccessToken, "two tokens should be the same") + }) + } +} + +func TestZitadelUpdateUserAppMetadata(t *testing.T) { + type updateUserAppMetadataTest struct { + name string + inputReqBody string + expectedReqBody string + appMetadata AppMetadata + statusCode int + helper ManagerHelper + managerCreds ManagerCredentials + assertErrFunc assert.ErrorAssertionFunc + assertErrFuncMessage string + } + + appMetadata := AppMetadata{WTAccountID: "ok"} + + updateUserAppMetadataTestCase1 := updateUserAppMetadataTest{ + name: "Bad Authentication", + expectedReqBody: "", + appMetadata: appMetadata, + statusCode: 400, + helper: JsonParser{}, + managerCreds: &mockZitadelCredentials{ + jwtToken: JWTToken{}, + err: fmt.Errorf("error"), + }, + assertErrFunc: assert.Error, + assertErrFuncMessage: "should return error", + } + + updateUserAppMetadataTestCase2 := updateUserAppMetadataTest{ + name: "Bad Response Parsing", + statusCode: 400, + helper: &mockJsonParser{marshalErrorString: "error"}, + managerCreds: &mockZitadelCredentials{ + jwtToken: JWTToken{}, + }, + assertErrFunc: assert.Error, + assertErrFuncMessage: "should return error", + } + + updateUserAppMetadataTestCase3 := updateUserAppMetadataTest{ + name: "Good request", + expectedReqBody: "{\"metadata\":[{\"key\":\"wt_account_id\",\"value\":\"b2s=\"},{\"key\":\"wt_pending_invite\",\"value\":\"ZmFsc2U=\"}]}", + appMetadata: appMetadata, + statusCode: 200, + helper: JsonParser{}, + managerCreds: &mockZitadelCredentials{ + jwtToken: JWTToken{}, + }, + assertErrFunc: assert.NoError, + assertErrFuncMessage: "shouldn't return error", + } + + invite := true + updateUserAppMetadataTestCase4 := updateUserAppMetadataTest{ + name: "Update Pending Invite", + expectedReqBody: "{\"metadata\":[{\"key\":\"wt_account_id\",\"value\":\"b2s=\"},{\"key\":\"wt_pending_invite\",\"value\":\"dHJ1ZQ==\"}]}", + appMetadata: AppMetadata{ + WTAccountID: "ok", + WTPendingInvite: &invite, + }, + statusCode: 200, + helper: JsonParser{}, + managerCreds: &mockZitadelCredentials{ + jwtToken: JWTToken{}, + }, + assertErrFunc: assert.NoError, + assertErrFuncMessage: "shouldn't return error", + } + + for _, testCase := range []updateUserAppMetadataTest{updateUserAppMetadataTestCase1, updateUserAppMetadataTestCase2, + updateUserAppMetadataTestCase3, updateUserAppMetadataTestCase4} { + t.Run(testCase.name, func(t *testing.T) { + reqClient := mockHTTPClient{ + resBody: testCase.inputReqBody, + code: testCase.statusCode, + } + + manager := &ZitadelManager{ + httpClient: &reqClient, + credentials: testCase.managerCreds, + helper: testCase.helper, + } + + err := manager.UpdateUserAppMetadata("1", testCase.appMetadata) + testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage) + + assert.Equal(t, testCase.expectedReqBody, reqClient.reqBody, "request body should match") + }) + } +} + +func TestZitadelProfile(t *testing.T) { + type azureProfileTest struct { + name string + invite bool + inputProfile zitadelProfile + expectedUserData UserData + } + + azureProfileTestCase1 := azureProfileTest{ + name: "User Request", + invite: false, + inputProfile: zitadelProfile{ + ID: "test1", + State: "USER_STATE_ACTIVE", + UserName: "test1@mail.com", + PreferredLoginName: "test1@mail.com", + LoginNames: []string{ + "test1@mail.com", + }, + Human: &zitadelUser{ + Profile: zitadelUserInfo{ + FirstName: "ZITADEL", + LastName: "Admin", + DisplayName: "ZITADEL Admin", + }, + Email: zitadelEmail{ + Email: "test1@mail.com", + IsEmailVerified: true, + }, + }, + Metadata: []zitadelMetadata{ + { + Key: "wt_account_id", + Value: "MQ==", + }, + { + Key: "wt_pending_invite", + Value: "ZmFsc2U=", + }, + }, + }, + expectedUserData: UserData{ + ID: "test1", + Name: "ZITADEL Admin", + Email: "test1@mail.com", + AppMetadata: AppMetadata{ + WTAccountID: "1", + }, + }, + } + + azureProfileTestCase2 := azureProfileTest{ + name: "Service User Request", + invite: true, + inputProfile: zitadelProfile{ + ID: "test2", + State: "USER_STATE_ACTIVE", + UserName: "machine", + PreferredLoginName: "machine", + LoginNames: []string{ + "machine", + }, + Human: nil, + Metadata: []zitadelMetadata{ + { + Key: "wt_account_id", + Value: "MQ==", + }, + { + Key: "wt_pending_invite", + Value: "dHJ1ZQ==", + }, + }, + }, + expectedUserData: UserData{ + ID: "test2", + Name: "machine", + Email: "machine", + AppMetadata: AppMetadata{ + WTAccountID: "1", + }, + }, + } + + for _, testCase := range []azureProfileTest{azureProfileTestCase1, azureProfileTestCase2} { + t.Run(testCase.name, func(t *testing.T) { + testCase.expectedUserData.AppMetadata.WTPendingInvite = &testCase.invite + userData := testCase.inputProfile.userData() + + assert.Equal(t, testCase.expectedUserData.ID, userData.ID, "User id should match") + assert.Equal(t, testCase.expectedUserData.Email, userData.Email, "User email should match") + assert.Equal(t, testCase.expectedUserData.Name, userData.Name, "User name should match") + assert.Equal(t, testCase.expectedUserData.AppMetadata.WTAccountID, userData.AppMetadata.WTAccountID, "Account id should match") + assert.Equal(t, testCase.expectedUserData.AppMetadata.WTPendingInvite, userData.AppMetadata.WTPendingInvite, "Pending invite should match") + }) + } +}