package idp import ( "bytes" "compress/gzip" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/golang-jwt/jwt" log "github.com/sirupsen/logrus" ) // Auth0Manager auth0 manager client instance type Auth0Manager struct { authIssuer string httpClient ManagerHTTPClient credentials ManagerCredentials helper ManagerHelper appMetrics telemetry.AppMetrics } // Auth0ClientConfig auth0 manager client configurations type Auth0ClientConfig struct { Audience string AuthIssuer string ClientID string ClientSecret string GrantType string } // auth0JWTRequest payload struct to request a JWT Token type auth0JWTRequest struct { Audience string `json:"audience"` AuthIssuer string `json:"auth_issuer"` ClientID string `json:"client_id"` ClientSecret string `json:"client_secret"` GrantType string `json:"grant_type"` } // Auth0Credentials auth0 authentication information type Auth0Credentials struct { clientConfig Auth0ClientConfig helper ManagerHelper httpClient ManagerHTTPClient jwtToken JWTToken mux sync.Mutex appMetrics telemetry.AppMetrics } // createUserRequest is a user create request type createUserRequest struct { Email string `json:"email"` Name string `json:"name"` AppMeta AppMetadata `json:"app_metadata"` Connection string `json:"connection"` Password string `json:"password"` VerifyEmail bool `json:"verify_email"` } // userExportJobRequest is a user export request struct type userExportJobRequest struct { Format string `json:"format"` Fields []map[string]string `json:"fields"` } // userExportJobResponse is a user export response struct type userExportJobResponse struct { Type string `json:"type"` Status string `json:"status"` ConnectionID string `json:"connection_id"` Format string `json:"format"` Limit int `json:"limit"` Connection string `json:"connection"` CreatedAt time.Time `json:"created_at"` ID string `json:"id"` } // userExportJobStatusResponse is a user export status response struct type userExportJobStatusResponse struct { Type string `json:"type"` Status string `json:"status"` ConnectionID string `json:"connection_id"` Format string `json:"format"` Limit int `json:"limit"` Location string `json:"location"` Connection string `json:"connection"` CreatedAt time.Time `json:"created_at"` ID string `json:"id"` } // userVerificationJobRequest is a user verification request struct type userVerificationJobRequest struct { UserID string `json:"user_id"` } // auth0Profile represents an Auth0 user profile response type auth0Profile struct { AccountID string `json:"wt_account_id"` PendingInvite bool `json:"wt_pending_invite"` UserID string `json:"user_id"` Name string `json:"name"` Email string `json:"email"` CreatedAt string `json:"created_at"` LastLogin string `json:"last_login"` } // Connections represents a single Auth0 connection // https://auth0.com/docs/api/management/v2/connections/get-connections type Connection struct { Id string `json:"id"` Name string `json:"name"` DisplayName string `json:"display_name"` IsDomainConnection bool `json:"is_domain_connection"` Realms []string `json:"realms"` Metadata map[string]string `json:"metadata"` Options ConnectionOptions `json:"options"` } type ConnectionOptions struct { DomainAliases []string `json:"domain_aliases"` } // NewAuth0Manager creates a new instance of the Auth0Manager func NewAuth0Manager(config Auth0ClientConfig, appMetrics telemetry.AppMetrics) (*Auth0Manager, error) { httpTransport := http.DefaultTransport.(*http.Transport).Clone() httpTransport.MaxIdleConns = 5 httpClient := &http.Client{ Timeout: 10 * time.Second, Transport: httpTransport, } helper := JsonParser{} if config.AuthIssuer == "" { return nil, fmt.Errorf("auth0 IdP configuration is incomplete, AuthIssuer is missing") } if config.ClientID == "" { return nil, fmt.Errorf("auth0 IdP configuration is incomplete, ClientID is missing") } if config.ClientSecret == "" { return nil, fmt.Errorf("auth0 IdP configuration is incomplete, ClientSecret is missing") } if config.Audience == "" { return nil, fmt.Errorf("auth0 IdP configuration is incomplete, Audience is missing") } if config.GrantType == "" { return nil, fmt.Errorf("auth0 IdP configuration is incomplete, GrantType is missing") } credentials := &Auth0Credentials{ clientConfig: config, httpClient: httpClient, helper: helper, appMetrics: appMetrics, } return &Auth0Manager{ authIssuer: config.AuthIssuer, credentials: credentials, httpClient: httpClient, 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 Auth0 func (c *Auth0Credentials) jwtStillValid() bool { return !c.jwtToken.expiresInTime.IsZero() && time.Now().Add(5*time.Second).Before(c.jwtToken.expiresInTime) } // requestJWTToken performs request to get jwt token func (c *Auth0Credentials) requestJWTToken(ctx context.Context) (*http.Response, error) { var res *http.Response reqURL := c.clientConfig.AuthIssuer + "/oauth/token" p, err := c.helper.Marshal(auth0JWTRequest(c.clientConfig)) if err != nil { return res, err } payload := strings.NewReader(string(p)) req, err := http.NewRequest("POST", reqURL, payload) if err != nil { return res, err } req.Header.Add("content-type", "application/json") log.WithContext(ctx).Debug("requesting new jwt token for idp manager") res, err = c.httpClient.Do(req) if err != nil { if c.appMetrics != nil { c.appMetrics.IDPMetrics().CountRequestError() } return res, err } if res.StatusCode != 200 { return res, fmt.Errorf("unable to get token, statusCode %d", res.StatusCode) } return res, nil } // parseRequestJWTResponse parses jwt raw response body and extracts token and expires in seconds func (c *Auth0Credentials) parseRequestJWTResponse(rawBody io.ReadCloser) (JWTToken, error) { jwtToken := JWTToken{} body, err := io.ReadAll(rawBody) if err != nil { return jwtToken, err } err = c.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 = json.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 Auth0 Management API func (c *Auth0Credentials) Authenticate(ctx context.Context) (JWTToken, error) { c.mux.Lock() defer c.mux.Unlock() if c.appMetrics != nil { c.appMetrics.IDPMetrics().CountAuthenticate() } // If jwtToken has an expires time and we have enough time to do a request return immediately if c.jwtStillValid() { return c.jwtToken, nil } res, err := c.requestJWTToken(ctx) if err != nil { return c.jwtToken, err } defer func() { err = res.Body.Close() if err != nil { log.WithContext(ctx).Errorf("error while closing get jwt token response body: %v", err) } }() jwtToken, err := c.parseRequestJWTResponse(res.Body) if err != nil { return c.jwtToken, err } c.jwtToken = jwtToken return c.jwtToken, nil } func batchRequestUsersURL(authIssuer, accountID string, page int, perPage int) (string, url.Values, error) { u, err := url.Parse(authIssuer + "/api/v2/users") if err != nil { return "", nil, err } q := u.Query() q.Set("page", strconv.Itoa(page)) q.Set("search_engine", "v3") q.Set("per_page", strconv.Itoa(perPage)) q.Set("q", "app_metadata.wt_account_id:"+accountID) u.RawQuery = q.Encode() return u.String(), q, nil } func requestByUserIDURL(authIssuer, userID string) string { return authIssuer + "/api/v2/users/" + userID } // GetAccount returns all the users for a given profile. Calls Auth0 API. func (am *Auth0Manager) GetAccount(ctx context.Context, accountID string) ([]*UserData, error) { jwtToken, err := am.credentials.Authenticate(ctx) if err != nil { return nil, err } var list []*UserData // https://auth0.com/docs/manage-users/user-search/retrieve-users-with-get-users-endpoint#limitations // auth0 limitation of 1000 users via this endpoint resultsPerPage := 50 for page := 0; page < 20; page++ { reqURL, query, err := batchRequestUsersURL(am.authIssuer, accountID, page, resultsPerPage) if err != nil { return nil, err } req, err := http.NewRequest(http.MethodGet, reqURL, strings.NewReader(query.Encode())) if err != nil { return nil, err } req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken) req.Header.Add("content-type", "application/json") res, err := am.httpClient.Do(req) if err != nil { if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestError() } return nil, err } if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountGetAccount() } body, err := io.ReadAll(res.Body) if err != nil { return nil, err } if res.StatusCode != 200 { return nil, fmt.Errorf("failed requesting user data from IdP %s", string(body)) } var batch []UserData err = json.Unmarshal(body, &batch) if err != nil { return nil, err } log.WithContext(ctx).Debugf("returned user batch for accountID %s on page %d, batch length %d", accountID, page, len(batch)) err = res.Body.Close() if err != nil { return nil, err } for user := range batch { list = append(list, &batch[user]) } if len(batch) == 0 || len(batch) < resultsPerPage { log.WithContext(ctx).Debugf("finished loading users for accountID %s", accountID) return list, nil } } return list, nil } // GetUserDataByID requests user data from auth0 via ID func (am *Auth0Manager) GetUserDataByID(ctx context.Context, userID string, appMetadata AppMetadata) (*UserData, error) { jwtToken, err := am.credentials.Authenticate(ctx) if err != nil { return nil, err } reqURL := requestByUserIDURL(am.authIssuer, userID) 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") res, err := am.httpClient.Do(req) if err != nil { if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestError() } return nil, err } if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountGetUserDataByID() } body, err := io.ReadAll(res.Body) if err != nil { return nil, err } var userData UserData err = json.Unmarshal(body, &userData) if err != nil { return nil, err } defer func() { err = res.Body.Close() if err != nil { log.WithContext(ctx).Errorf("error while closing update user app metadata response body: %v", err) } }() if res.StatusCode != 200 { return nil, fmt.Errorf("unable to get UserData, statusCode %d", res.StatusCode) } return &userData, nil } // UpdateUserAppMetadata updates user app metadata based on userId and metadata map func (am *Auth0Manager) UpdateUserAppMetadata(ctx context.Context, userID string, appMetadata AppMetadata) error { jwtToken, err := am.credentials.Authenticate(ctx) if err != nil { return err } reqURL := am.authIssuer + "/api/v2/users/" + userID data, err := am.helper.Marshal(map[string]any{"app_metadata": appMetadata}) if err != nil { return err } payload := strings.NewReader(string(data)) req, err := http.NewRequest("PATCH", reqURL, payload) if err != nil { return err } req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken) req.Header.Add("content-type", "application/json") log.WithContext(ctx).Debugf("updating IdP metadata for user %s", userID) res, err := am.httpClient.Do(req) if err != nil { if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestError() } return err } if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountUpdateUserAppMetadata() } defer func() { err = res.Body.Close() if err != nil { log.WithContext(ctx).Errorf("error while closing update user app metadata response body: %v", err) } }() if res.StatusCode != 200 { return fmt.Errorf("unable to update the appMetadata, statusCode %d", res.StatusCode) } return nil } func buildCreateUserRequestPayload(email, name, accountID, invitedByEmail string) (string, error) { invite := true req := &createUserRequest{ Email: email, Name: name, AppMeta: AppMetadata{ WTAccountID: accountID, WTPendingInvite: &invite, WTInvitedBy: invitedByEmail, }, Connection: "Username-Password-Authentication", Password: GeneratePassword(8, 1, 1, 1), VerifyEmail: true, } str, err := json.Marshal(req) if err != nil { return "", err } return string(str), nil } func buildUserExportRequest() (string, error) { req := &userExportJobRequest{} fields := make([]map[string]string, 0) for _, field := range []string{"created_at", "last_login", "user_id", "email", "name"} { fields = append(fields, map[string]string{"name": field}) } fields = append(fields, map[string]string{ "name": "app_metadata.wt_account_id", "export_as": "wt_account_id", }) fields = append(fields, map[string]string{ "name": "app_metadata.wt_pending_invite", "export_as": "wt_pending_invite", }) req.Format = "json" req.Fields = fields str, err := json.Marshal(req) if err != nil { return "", err } return string(str), nil } func (am *Auth0Manager) createRequest( ctx context.Context, method string, endpoint string, body io.Reader, ) (*http.Request, error) { jwtToken, err := am.credentials.Authenticate(ctx) if err != nil { return nil, err } reqURL := am.authIssuer + endpoint req, err := http.NewRequest(method, reqURL, body) if err != nil { return nil, err } req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken) return req, nil } func (am *Auth0Manager) createPostRequest(ctx context.Context, endpoint string, payloadStr string) (*http.Request, error) { req, err := am.createRequest(ctx, "POST", endpoint, strings.NewReader(payloadStr)) if err != nil { return nil, err } req.Header.Add("content-type", "application/json") return req, nil } // GetAllAccounts gets all registered accounts with corresponding user data. // It returns a list of users indexed by accountID. func (am *Auth0Manager) GetAllAccounts(ctx context.Context) (map[string][]*UserData, error) { payloadString, err := buildUserExportRequest() if err != nil { return nil, err } exportJobReq, err := am.createPostRequest(ctx, "/api/v2/jobs/users-exports", payloadString) if err != nil { return nil, err } jobResp, err := am.httpClient.Do(exportJobReq) if err != nil { log.WithContext(ctx).Debugf("Couldn't get job response %v", err) if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestError() } return nil, err } defer func() { err = jobResp.Body.Close() if err != nil { log.WithContext(ctx).Errorf("error while closing update user app metadata response body: %v", err) } }() if jobResp.StatusCode != 200 { if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestStatusError() } return nil, fmt.Errorf("unable to update the appMetadata, statusCode %d", jobResp.StatusCode) } var exportJobResp userExportJobResponse body, err := io.ReadAll(jobResp.Body) if err != nil { log.WithContext(ctx).Debugf("Couldn't read export job response; %v", err) return nil, err } err = am.helper.Unmarshal(body, &exportJobResp) if err != nil { log.WithContext(ctx).Debugf("Couldn't unmarshal export job response; %v", err) return nil, err } if exportJobResp.ID == "" { if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestStatusError() } return nil, fmt.Errorf("couldn't get an batch id status %d, %s, response body: %v", jobResp.StatusCode, jobResp.Status, exportJobResp) } log.WithContext(ctx).Debugf("batch id status %d, %s, response body: %v", jobResp.StatusCode, jobResp.Status, exportJobResp) done, downloadLink, err := am.checkExportJobStatus(ctx, exportJobResp.ID) if err != nil { log.WithContext(ctx).Debugf("Failed at getting status checks from exportJob; %v", err) return nil, err } if done { return am.downloadProfileExport(ctx, downloadLink) } return nil, fmt.Errorf("failed extracting user profiles from auth0") } // GetUserByEmail searches users with a given email. If no users have been found, this function returns an empty list. // This function can return multiple users. This is due to the Auth0 internals - there could be multiple users with // the same email but different connections that are considered as separate accounts (e.g., Google and username/password). func (am *Auth0Manager) GetUserByEmail(ctx context.Context, email string) ([]*UserData, error) { jwtToken, err := am.credentials.Authenticate(ctx) if err != nil { return nil, err } reqURL := am.authIssuer + "/api/v2/users-by-email?email=" + url.QueryEscape(email) body, err := doGetReq(ctx, am.httpClient, reqURL, jwtToken.AccessToken) if err != nil { return nil, err } if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountGetUserByEmail() } userResp := []*UserData{} err = am.helper.Unmarshal(body, &userResp) if err != nil { log.WithContext(ctx).Debugf("Couldn't unmarshal export job response; %v", err) return nil, err } return userResp, nil } // CreateUser creates a new user in Auth0 Idp and sends an invite func (am *Auth0Manager) CreateUser(ctx context.Context, email, name, accountID, invitedByEmail string) (*UserData, error) { payloadString, err := buildCreateUserRequestPayload(email, name, accountID, invitedByEmail) if err != nil { return nil, err } req, err := am.createPostRequest(ctx, "/api/v2/users", payloadString) if err != nil { return nil, err } if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountCreateUser() } resp, err := am.httpClient.Do(req) if err != nil { log.WithContext(ctx).Debugf("Couldn't get job response %v", err) if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestError() } return nil, err } defer func() { err = resp.Body.Close() if err != nil { log.WithContext(ctx).Errorf("error while closing create user response body: %v", err) } }() if !(resp.StatusCode == 200 || resp.StatusCode == 201) { if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestStatusError() } return nil, fmt.Errorf("unable to create user, statusCode %d", resp.StatusCode) } var createResp UserData body, err := io.ReadAll(resp.Body) if err != nil { log.WithContext(ctx).Debugf("Couldn't read export job response; %v", err) return nil, err } err = am.helper.Unmarshal(body, &createResp) if err != nil { log.WithContext(ctx).Debugf("Couldn't unmarshal export job response; %v", err) return nil, err } if createResp.ID == "" { return nil, fmt.Errorf("couldn't create user: response %v", resp) } log.WithContext(ctx).Debugf("created user %s in account %s", createResp.ID, accountID) return &createResp, nil } // InviteUserByID resend invitations to users who haven't activated, // their accounts prior to the expiration period. func (am *Auth0Manager) InviteUserByID(ctx context.Context, userID string) error { userVerificationReq := userVerificationJobRequest{ UserID: userID, } payload, err := am.helper.Marshal(userVerificationReq) if err != nil { return err } req, err := am.createPostRequest(ctx, "/api/v2/jobs/verification-email", string(payload)) if err != nil { return err } resp, err := am.httpClient.Do(req) if err != nil { log.WithContext(ctx).Debugf("Couldn't get job response %v", err) if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestError() } return err } defer func() { err = resp.Body.Close() if err != nil { log.WithContext(ctx).Errorf("error while closing invite user response body: %v", err) } }() if !(resp.StatusCode == 200 || resp.StatusCode == 201) { if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestStatusError() } return fmt.Errorf("unable to invite user, statusCode %d", resp.StatusCode) } return nil } // DeleteUser from Auth0 func (am *Auth0Manager) DeleteUser(ctx context.Context, userID string) error { req, err := am.createRequest(ctx, http.MethodDelete, "/api/v2/users/"+url.QueryEscape(userID), nil) if err != nil { return err } resp, err := am.httpClient.Do(req) if err != nil { log.WithContext(ctx).Debugf("execute delete request: %v", err) if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestError() } return err } defer func() { err = resp.Body.Close() if err != nil { log.WithContext(ctx).Errorf("close delete request body: %v", err) } }() if resp.StatusCode != 204 { if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestStatusError() } return fmt.Errorf("unable to delete user, statusCode %d", resp.StatusCode) } return nil } // GetAllConnections returns detailed list of all connections filtered by given params. // Note this method is not part of the IDP Manager interface as this is Auth0 specific. func (am *Auth0Manager) GetAllConnections(ctx context.Context, strategy []string) ([]Connection, error) { var connections []Connection q := make(url.Values) q.Set("strategy", strings.Join(strategy, ",")) req, err := am.createRequest(ctx, http.MethodGet, "/api/v2/connections?"+q.Encode(), nil) if err != nil { return connections, err } resp, err := am.httpClient.Do(req) if err != nil { log.WithContext(ctx).Debugf("execute get connections request: %v", err) if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestError() } return connections, err } defer func() { err = resp.Body.Close() if err != nil { log.WithContext(ctx).Errorf("close get connections request body: %v", err) } }() if resp.StatusCode != 200 { if am.appMetrics != nil { am.appMetrics.IDPMetrics().CountRequestStatusError() } return connections, fmt.Errorf("unable to get connections, statusCode %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { log.WithContext(ctx).Debugf("Couldn't read get connections response; %v", err) return connections, err } err = am.helper.Unmarshal(body, &connections) if err != nil { log.WithContext(ctx).Debugf("Couldn't unmarshal get connection response; %v", err) return connections, err } return connections, err } // checkExportJobStatus checks the status of the job created at CreateExportUsersJob. // If the status is "completed", then return the downloadLink func (am *Auth0Manager) checkExportJobStatus(ctx context.Context, jobID string) (bool, string, error) { ctx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() retry := time.NewTicker(10 * time.Second) for { select { case <-ctx.Done(): log.WithContext(ctx).Debugf("Export job status stopped...\n") return false, "", ctx.Err() case <-retry.C: jwtToken, err := am.credentials.Authenticate(ctx) if err != nil { return false, "", err } statusURL := am.authIssuer + "/api/v2/jobs/" + jobID body, err := doGetReq(ctx, am.httpClient, statusURL, jwtToken.AccessToken) if err != nil { return false, "", err } var status userExportJobStatusResponse err = am.helper.Unmarshal(body, &status) if err != nil { return false, "", err } log.WithContext(ctx).Debugf("current export job status is %v", status.Status) if status.Status != "completed" { continue } return true, status.Location, nil } } } // downloadProfileExport downloads user profiles from auth0 batch job func (am *Auth0Manager) downloadProfileExport(ctx context.Context, location string) (map[string][]*UserData, error) { body, err := doGetReq(ctx, am.httpClient, location, "") if err != nil { return nil, err } bodyReader := bytes.NewReader(body) gzipReader, err := gzip.NewReader(bodyReader) if err != nil { return nil, err } decoder := json.NewDecoder(gzipReader) res := make(map[string][]*UserData) for decoder.More() { profile := auth0Profile{} err = decoder.Decode(&profile) if err != nil { return nil, err } if profile.AccountID != "" { if _, ok := res[profile.AccountID]; !ok { res[profile.AccountID] = []*UserData{} } res[profile.AccountID] = append(res[profile.AccountID], &UserData{ ID: profile.UserID, Name: profile.Name, Email: profile.Email, AppMetadata: AppMetadata{ WTAccountID: profile.AccountID, WTPendingInvite: &profile.PendingInvite, }, }) } } return res, nil } // Boilerplate implementation for Get Requests. func doGetReq(ctx context.Context, client ManagerHTTPClient, url, accessToken string) ([]byte, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } if accessToken != "" { req.Header.Add("authorization", "Bearer "+accessToken) } res, err := client.Do(req) if err != nil { return nil, err } defer func() { err = res.Body.Close() if err != nil { log.WithContext(ctx).Errorf("error while closing body for url %s: %v", url, err) } }() body, err := io.ReadAll(res.Body) if err != nil { return nil, err } if res.StatusCode != 200 { return nil, fmt.Errorf("unable to get %s, statusCode %d", url, res.StatusCode) } return body, nil }