mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
Add Okta IdP (#859)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
383
management/server/idp/okta.go
Normal file
383
management/server/idp/okta.go
Normal file
@@ -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
|
||||
}
|
||||
95
management/server/idp/okta_test.go
Normal file
95
management/server/idp/okta_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user