From 2eb9a97fee4de01bb96912237765df9796a4cb9c Mon Sep 17 00:00:00 2001 From: Bethuel Date: Mon, 29 May 2023 15:52:04 +0300 Subject: [PATCH] Add Okta IdP (#859) --- go.mod | 3 + go.sum | 6 + management/server/idp/idp.go | 15 +- management/server/idp/okta.go | 383 +++++++++++++++++++++++++++++ management/server/idp/okta_test.go | 95 +++++++ management/server/idp/util.go | 12 + 6 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 management/server/idp/okta.go create mode 100644 management/server/idp/okta_test.go diff --git a/go.mod b/go.mod index bfafeb500..c674cf06a 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( github.com/mdlayher/socket v0.4.0 github.com/miekg/dns v1.1.43 github.com/mitchellh/hashstructure/v2 v2.0.2 + github.com/okta/okta-sdk-golang/v2 v2.18.0 github.com/open-policy-agent/opa v0.49.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pion/logging v0.2.2 @@ -99,6 +100,7 @@ require ( github.com/hashicorp/go-uuid v1.0.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/native v1.0.0 // indirect + github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mdlayher/genetlink v1.1.0 // indirect github.com/mdlayher/netlink v1.7.1 // indirect @@ -135,6 +137,7 @@ require ( golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 // indirect + gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index f18f9c544..5db15143c 100644 --- a/go.sum +++ b/go.sum @@ -406,6 +406,8 @@ github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/X github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -496,6 +498,8 @@ github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/okta/okta-sdk-golang/v2 v2.18.0 h1:cfDasMb7CShbZvOrF6n+DnLevWwiHgedWMGJ8M8xKDc= +github.com/okta/okta-sdk-golang/v2 v2.18.0/go.mod h1:dz30v3ctAiMb7jpsCngGfQUAEGm1/NsWT92uTbNDQIs= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -1157,6 +1161,8 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV4fzYhNBql77zY0ykqs= diff --git a/management/server/idp/idp.go b/management/server/idp/idp.go index 79b945bc0..03f4cb5cc 100644 --- a/management/server/idp/idp.go +++ b/management/server/idp/idp.go @@ -41,6 +41,7 @@ type Config struct { KeycloakClientCredentials KeycloakClientConfig ZitadelClientCredentials ZitadelClientConfig AuthentikClientCredentials AuthentikClientConfig + OktaClientCredentials OktaClientConfig } // ManagerCredentials interface that authenticates using the credential of each type of idp @@ -141,7 +142,6 @@ func NewManager(config Config, appMetrics telemetry.AppMetrics) (Manager, error) } return NewZitadelManager(zitadelClientConfig, appMetrics) - case "authentik": authentikConfig := config.AuthentikClientCredentials if config.ClientConfig != nil { @@ -156,6 +156,19 @@ func NewManager(config Config, appMetrics telemetry.AppMetrics) (Manager, error) } return NewAuthentikManager(authentikConfig, appMetrics) + case "okta": + oktaClientConfig := config.OktaClientCredentials + if config.ClientConfig != nil { + oktaClientConfig = OktaClientConfig{ + Issuer: config.ClientConfig.Issuer, + TokenEndpoint: config.ClientConfig.TokenEndpoint, + GrantType: config.ClientConfig.GrantType, + APIToken: config.ExtraConfig["APIToken"], + } + } + + return NewOktaManager(oktaClientConfig, appMetrics) + default: return nil, fmt.Errorf("invalid manager type: %s", config.ManagerType) } diff --git a/management/server/idp/okta.go b/management/server/idp/okta.go new file mode 100644 index 000000000..cc0143464 --- /dev/null +++ b/management/server/idp/okta.go @@ -0,0 +1,383 @@ +package idp + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/okta/okta-sdk-golang/v2/okta" + "github.com/okta/okta-sdk-golang/v2/okta/query" +) + +// OktaManager okta manager client instance. +type OktaManager struct { + client *okta.Client + httpClient ManagerHTTPClient + credentials ManagerCredentials + helper ManagerHelper + appMetrics telemetry.AppMetrics +} + +// OktaClientConfig okta manager client configurations. +type OktaClientConfig struct { + APIToken string + Issuer string + TokenEndpoint string + GrantType string +} + +// OktaCredentials okta authentication information. +type OktaCredentials struct { + clientConfig OktaClientConfig + helper ManagerHelper + httpClient ManagerHTTPClient + appMetrics telemetry.AppMetrics +} + +// NewOktaManager creates a new instance of the OktaManager. +func NewOktaManager(config OktaClientConfig, appMetrics telemetry.AppMetrics) (*OktaManager, error) { + httpTransport := http.DefaultTransport.(*http.Transport).Clone() + httpTransport.MaxIdleConns = 5 + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: httpTransport, + } + + helper := JsonParser{} + config.Issuer = baseURL(config.Issuer) + + if config.APIToken == "" { + return nil, fmt.Errorf("okta IdP configuration is incomplete, APIToken is missing") + } + + if config.Issuer == "" { + return nil, fmt.Errorf("okta IdP configuration is incomplete, Issuer is missing") + } + + if config.TokenEndpoint == "" { + return nil, fmt.Errorf("okta IdP configuration is incomplete, TokenEndpoint is missing") + } + + if config.GrantType == "" { + return nil, fmt.Errorf("okta IdP configuration is incomplete, GrantType is missing") + } + + _, client, err := okta.NewClient(context.Background(), + okta.WithOrgUrl(config.Issuer), + okta.WithToken(config.APIToken), + okta.WithHttpClientPtr(httpClient), + ) + if err != nil { + return nil, err + } + + err = updateUserProfileSchema(client) + if err != nil { + return nil, err + } + + credentials := &OktaCredentials{ + clientConfig: config, + httpClient: httpClient, + helper: helper, + appMetrics: appMetrics, + } + + return &OktaManager{ + client: client, + httpClient: httpClient, + credentials: credentials, + helper: helper, + appMetrics: appMetrics, + }, nil +} + +// Authenticate retrieves access token to use the okta user API. +func (oc *OktaCredentials) Authenticate() (JWTToken, error) { + return JWTToken{}, nil +} + +// CreateUser creates a new user in okta Idp and sends an invitation. +func (om *OktaManager) CreateUser(email string, name string, accountID string) (*UserData, error) { + var ( + sendEmail = true + activate = true + userProfile = okta.UserProfile{ + "email": email, + "login": email, + wtAccountID: accountID, + wtPendingInvite: true, + } + ) + + fields := strings.Fields(name) + if n := len(fields); n > 0 { + userProfile["firstName"] = strings.Join(fields[:n-1], " ") + userProfile["lastName"] = fields[n-1] + } + + user, resp, err := om.client.User.CreateUser(context.Background(), + okta.CreateUserRequest{ + Profile: &userProfile, + }, + &query.Params{ + Activate: &activate, + SendEmail: &sendEmail, + }, + ) + if err != nil { + return nil, err + } + + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountCreateUser() + } + + if resp.StatusCode != http.StatusOK { + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to create user, statusCode %d", resp.StatusCode) + } + + return parseOktaUser(user) +} + +// GetUserDataByID requests user data from keycloak via ID. +func (om *OktaManager) GetUserDataByID(userID string, appMetadata AppMetadata) (*UserData, error) { + user, resp, err := om.client.User.GetUser(context.Background(), userID) + if err != nil { + return nil, err + } + + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountGetUserDataByID() + } + + if resp.StatusCode != http.StatusOK { + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to get user %s, statusCode %d", userID, resp.StatusCode) + } + + return parseOktaUser(user) +} + +// GetUserByEmail searches users with a given email. +// If no users have been found, this function returns an empty list. +func (om *OktaManager) GetUserByEmail(email string) ([]*UserData, error) { + user, resp, err := om.client.User.GetUser(context.Background(), url.QueryEscape(email)) + if err != nil { + return nil, err + } + + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountGetUserByEmail() + } + + if resp.StatusCode != http.StatusOK { + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to get user %s, statusCode %d", email, resp.StatusCode) + } + + userData, err := parseOktaUser(user) + if err != nil { + return nil, err + } + users := make([]*UserData, 0) + users = append(users, userData) + + return users, nil +} + +// GetAccount returns all the users for a given profile. +func (om *OktaManager) GetAccount(accountID string) ([]*UserData, error) { + search := fmt.Sprintf("profile.wt_account_id eq %q", accountID) + users, resp, err := om.client.User.ListUsers(context.Background(), &query.Params{Search: search}) + if err != nil { + return nil, err + } + + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountGetAccount() + } + + if resp.StatusCode != http.StatusOK { + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to get account, statusCode %d", resp.StatusCode) + } + + list := make([]*UserData, 0) + for _, user := range users { + userData, err := parseOktaUser(user) + if err != nil { + return nil, err + } + + list = append(list, userData) + } + + return list, nil +} + +// GetAllAccounts gets all registered accounts with corresponding user data. +// It returns a list of users indexed by accountID. +func (om *OktaManager) GetAllAccounts() (map[string][]*UserData, error) { + users, resp, err := om.client.User.ListUsers(context.Background(), nil) + if err != nil { + return nil, err + } + + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountGetAllAccounts() + } + + if resp.StatusCode != http.StatusOK { + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to get all accounts, statusCode %d", resp.StatusCode) + } + + indexedUsers := make(map[string][]*UserData) + for _, user := range users { + userData, err := parseOktaUser(user) + if err != nil { + return nil, err + } + + 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. +func (om *OktaManager) UpdateUserAppMetadata(userID string, appMetadata AppMetadata) error { + var pendingInvite bool + if appMetadata.WTPendingInvite != nil { + pendingInvite = *appMetadata.WTPendingInvite + } + + _, resp, err := om.client.User.UpdateUser(context.Background(), userID, + okta.User{ + Profile: &okta.UserProfile{ + wtAccountID: appMetadata.WTAccountID, + wtPendingInvite: pendingInvite, + }, + }, + nil, + ) + if err != nil { + return err + } + + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountUpdateUserAppMetadata() + } + + if resp.StatusCode != http.StatusOK { + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountRequestStatusError() + } + return fmt.Errorf("unable to update user, statusCode %d", resp.StatusCode) + } + + return nil +} + +// updateUserProfileSchema updates the Okta user schema to include custom fields, +// wt_account_id and wt_pending_invite. +func updateUserProfileSchema(client *okta.Client) error { + required := true + _, resp, err := client.UserSchema.UpdateUserProfile( + context.Background(), + "default", + okta.UserSchema{ + Definitions: &okta.UserSchemaDefinitions{ + Custom: &okta.UserSchemaPublic{ + Id: "#custom", + Type: "object", + Properties: map[string]*okta.UserSchemaAttribute{ + wtAccountID: { + MaxLength: 100, + MinLength: 1, + Required: &required, + Scope: "NONE", + Title: "Wt Account Id", + Type: "string", + }, + wtPendingInvite: { + Required: new(bool), + Scope: "NONE", + Title: "Wt Pending Invite", + Type: "boolean", + }, + }, + }, + }, + }) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unable to update user profile schema, statusCode %d", resp.StatusCode) + } + + return nil +} + +// parseOktaUserToUserData parse okta user to UserData. +func parseOktaUser(user *okta.User) (*UserData, error) { + var oktaUser struct { + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + AccountID string `json:"wt_account_id"` + PendingInvite bool `json:"wt_pending_invite"` + } + + if user == nil { + return nil, fmt.Errorf("invalid okta user") + } + + if user.Profile != nil { + helper := JsonParser{} + buf, err := helper.Marshal(*user.Profile) + if err != nil { + return nil, err + } + + err = helper.Unmarshal(buf, &oktaUser) + if err != nil { + return nil, err + } + } + + return &UserData{ + Email: oktaUser.Email, + Name: strings.Join([]string{oktaUser.FirstName, oktaUser.LastName}, " "), + ID: user.Id, + AppMetadata: AppMetadata{ + WTAccountID: oktaUser.AccountID, + WTPendingInvite: &oktaUser.PendingInvite, + }, + }, nil +} diff --git a/management/server/idp/okta_test.go b/management/server/idp/okta_test.go new file mode 100644 index 000000000..02c28b3ae --- /dev/null +++ b/management/server/idp/okta_test.go @@ -0,0 +1,95 @@ +package idp + +import ( + "github.com/okta/okta-sdk-golang/v2/okta" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestParseOktaUser(t *testing.T) { + type parseOktaUserTest struct { + name string + invite bool + inputProfile *okta.User + expectedUserData *UserData + assertErrFunc assert.ErrorAssertionFunc + } + + parseOktaTestCase1 := parseOktaUserTest{ + name: "Good Request", + invite: true, + inputProfile: &okta.User{ + Id: "123", + Profile: &okta.UserProfile{ + "email": "test@example.com", + "firstName": "John", + "lastName": "Doe", + "wt_account_id": "456", + "wt_pending_invite": true, + }, + }, + expectedUserData: &UserData{ + Email: "test@example.com", + Name: "John Doe", + ID: "123", + AppMetadata: AppMetadata{ + WTAccountID: "456", + }, + }, + assertErrFunc: assert.NoError, + } + + parseOktaTestCase2 := parseOktaUserTest{ + name: "Invalid okta user", + invite: true, + inputProfile: nil, + expectedUserData: nil, + assertErrFunc: assert.Error, + } + + parseOktaTestCase3 := parseOktaUserTest{ + name: "Invalid pending invite type", + invite: false, + inputProfile: &okta.User{ + Id: "123", + Profile: &okta.UserProfile{ + "email": "test@example.com", + "firstName": "John", + "lastName": "Doe", + "wt_account_id": "456", + "wt_pending_invite": "true", + }, + }, + expectedUserData: nil, + assertErrFunc: assert.Error, + } + + for _, testCase := range []parseOktaUserTest{parseOktaTestCase1, parseOktaTestCase2, parseOktaTestCase3} { + t.Run(testCase.name, func(t *testing.T) { + userData, err := parseOktaUser(testCase.inputProfile) + testCase.assertErrFunc(t, err, testCase.assertErrFunc) + + if err == nil { + testCase.expectedUserData.AppMetadata.WTPendingInvite = &testCase.invite + assert.True(t, userDataEqual(testCase.expectedUserData, userData), "user data should match") + } + }) + + } +} + +// userDataEqual helper function to compare UserData structs for equality. +func userDataEqual(a, b *UserData) bool { + if a.Email != b.Email || a.Name != b.Name || a.ID != b.ID { + return false + } + if a.AppMetadata.WTAccountID != b.AppMetadata.WTAccountID { + return false + } + + if a.AppMetadata.WTPendingInvite != nil && b.AppMetadata.WTPendingInvite != nil && + *a.AppMetadata.WTPendingInvite != *b.AppMetadata.WTPendingInvite { + return false + } + return true +} diff --git a/management/server/idp/util.go b/management/server/idp/util.go index 2401a7207..df1497114 100644 --- a/management/server/idp/util.go +++ b/management/server/idp/util.go @@ -3,6 +3,7 @@ package idp import ( "encoding/json" "math/rand" + "net/url" "strings" ) @@ -57,3 +58,14 @@ func GeneratePassword(passwordLength, minSpecialChar, minNum, minUpperCase int) }) return string(inRune) } + +// baseURL returns the base url by concatenating +// the scheme and host components of the parsed URL. +func baseURL(rawURL string) string { + parsedURL, err := url.Parse(rawURL) + if err != nil { + return "" + } + + return parsedURL.Scheme + "://" + parsedURL.Host +}