forked from baron/baron-sso
chore: consolidate local integration changes
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
@@ -17,11 +18,13 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWorksmobileOAuthScope = "directory"
|
||||
defaultWorksmobileOAuthScope = "directory"
|
||||
worksmobileAPIRateLimitPerMinute = 240
|
||||
)
|
||||
|
||||
type WorksmobileDirectoryClient interface {
|
||||
@@ -43,6 +46,7 @@ type WorksmobileHTTPClient struct {
|
||||
DirectoryToken string
|
||||
SCIMToken string
|
||||
HTTPClient *http.Client
|
||||
RateLimiter WorksmobileRateLimiter
|
||||
OAuthConfig WorksmobileOAuthConfig
|
||||
DomainIDs []int64
|
||||
OrgUnitWriteDelay time.Duration
|
||||
@@ -50,6 +54,16 @@ type WorksmobileHTTPClient struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type WorksmobileRateLimiter interface {
|
||||
Wait(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
type worksmobileAPIRateLimiter struct {
|
||||
interval time.Duration
|
||||
mu sync.Mutex
|
||||
next map[string]time.Time
|
||||
}
|
||||
|
||||
type WorksmobileOAuthConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
@@ -64,6 +78,46 @@ type worksmobileAccessTokenCache struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func NewWorksmobileAPIRateLimiter(limit int, window time.Duration) WorksmobileRateLimiter {
|
||||
if limit <= 0 || window <= 0 {
|
||||
return &worksmobileAPIRateLimiter{}
|
||||
}
|
||||
return &worksmobileAPIRateLimiter{
|
||||
interval: window / time.Duration(limit),
|
||||
next: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *worksmobileAPIRateLimiter) Wait(ctx context.Context, key string) error {
|
||||
if l == nil || l.interval <= 0 {
|
||||
return nil
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
key = "UNKNOWN"
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
now := time.Now()
|
||||
waitUntil := l.next[key]
|
||||
if waitUntil.Before(now) {
|
||||
waitUntil = now
|
||||
}
|
||||
l.next[key] = waitUntil.Add(l.interval)
|
||||
l.mu.Unlock()
|
||||
|
||||
if delay := time.Until(waitUntil); delay > 0 {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c WorksmobileOAuthConfig) normalized() WorksmobileOAuthConfig {
|
||||
c.ClientID = strings.Trim(strings.TrimSpace(c.ClientID), `"`)
|
||||
c.ClientSecret = strings.Trim(strings.TrimSpace(c.ClientSecret), `"`)
|
||||
@@ -280,7 +334,10 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(payload.UserExternalKey)
|
||||
}
|
||||
return c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload))
|
||||
if patchErr := c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload)); patchErr != nil {
|
||||
return fmt.Errorf("worksmobile user create conflict: %w; patch after conflict failed: %v", err, patchErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -306,6 +363,23 @@ func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID st
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) RemoveUserAliasEmail(ctx context.Context, userID string, email string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
email = strings.TrimSpace(email)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
if email == "" {
|
||||
return fmt.Errorf("worksmobile alias email is required")
|
||||
}
|
||||
return c.sendDirectoryJSON(
|
||||
ctx,
|
||||
http.MethodDelete,
|
||||
"/v1.0/users/"+url.PathEscape(userID)+"/alias-emails/"+url.PathEscape(email),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID string, password string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
password = strings.TrimSpace(password)
|
||||
@@ -315,15 +389,38 @@ func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID st
|
||||
if password == "" {
|
||||
return fmt.Errorf("worksmobile password is required")
|
||||
}
|
||||
changePasswordAtNextLogin := true
|
||||
payload := map[string]any{
|
||||
"passwordConfig": WorksmobilePasswordConfig{
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: password,
|
||||
PasswordCreationType: "ADMIN",
|
||||
Password: password,
|
||||
ChangePasswordAtNextLogin: &changePasswordAtNextLogin,
|
||||
},
|
||||
}
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(userID), payload)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) GetUser(ctx context.Context, userID string) (*WorksmobileRemoteUser, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
var response map[string]any
|
||||
if err := c.getDirectoryJSON(ctx, "/v1.0/users/"+url.PathEscape(userID), &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := parseWorksmobileDirectoryUser(response)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) UndeleteUser(ctx context.Context, userID string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users/"+url.PathEscape(userID)+"/undelete", nil)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
@@ -484,6 +581,9 @@ func (c *WorksmobileHTTPClient) getJSON(ctx context.Context, path string, target
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -512,6 +612,9 @@ func (c *WorksmobileHTTPClient) getDirectoryJSON(ctx context.Context, path strin
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -665,6 +768,9 @@ func (c *WorksmobileHTTPClient) requestDirectoryAccessToken(ctx context.Context,
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
@@ -729,6 +835,9 @@ func (c *WorksmobileHTTPClient) sendJSONWithToken(ctx context.Context, method st
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
if err := c.waitForWorksmobileAPI(ctx, req.Method, req.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -801,6 +910,7 @@ type WorksmobileRemoteUser struct {
|
||||
ExternalID string `json:"externalId"`
|
||||
UserName string `json:"userName"`
|
||||
Email string `json:"email"`
|
||||
AliasEmails []string `json:"aliasEmails,omitempty"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CellPhone string `json:"cellPhone,omitempty"`
|
||||
EmployeeNumber string `json:"employeeNumber,omitempty"`
|
||||
@@ -817,6 +927,10 @@ type WorksmobileRemoteUser struct {
|
||||
OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"`
|
||||
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
IsAwaiting bool `json:"isAwaiting"`
|
||||
IsPending bool `json:"isPending"`
|
||||
IsSuspended bool `json:"isSuspended"`
|
||||
IsDeleted bool `json:"isDeleted"`
|
||||
}
|
||||
|
||||
type WorksmobileRemoteGroup struct {
|
||||
@@ -852,18 +966,22 @@ func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSC
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(payload.CellPhone) != "" {
|
||||
result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: strings.TrimSpace(payload.CellPhone), Primary: true, Type: "mobile"}}
|
||||
result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: normalizeWorksmobileOutboundCellPhone(payload.CellPhone), Primary: true, Type: "mobile"}}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeWorksmobileOutboundCellPhone(value string) string {
|
||||
return domain.NormalizePhoneNumber(value)
|
||||
}
|
||||
|
||||
func NewWorksmobileUserPatchPayload(payload WorksmobileUserPayload) WorksmobileUserPatchPayload {
|
||||
return WorksmobileUserPatchPayload{
|
||||
DomainID: payload.DomainID,
|
||||
Email: strings.TrimSpace(payload.Email),
|
||||
UserExternalKey: strings.TrimSpace(payload.UserExternalKey),
|
||||
UserName: payload.UserName,
|
||||
CellPhone: strings.TrimSpace(payload.CellPhone),
|
||||
CellPhone: normalizeWorksmobileOutboundCellPhone(payload.CellPhone),
|
||||
EmployeeNumber: strings.TrimSpace(payload.EmployeeNumber),
|
||||
AliasEmails: payload.AliasEmails,
|
||||
Locale: strings.TrimSpace(payload.Locale),
|
||||
@@ -937,6 +1055,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
ExternalID: firstStringFromMap(resource, "userExternalKey", "externalKey", "externalId"),
|
||||
UserName: email,
|
||||
Email: email,
|
||||
AliasEmails: stringListFromMap(resource, "aliasEmails"),
|
||||
DisplayName: parseWorksmobileDirectoryUserName(resource),
|
||||
CellPhone: firstStringFromMap(resource, "cellPhone", "phoneNumber", "phone", "mobile", "mobilePhone"),
|
||||
EmployeeNumber: firstStringFromMap(
|
||||
@@ -954,6 +1073,10 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
|
||||
if active, ok := resource["active"].(bool); ok {
|
||||
user.Active = active
|
||||
}
|
||||
user.IsAwaiting = boolFromMap(resource, "isAwaiting")
|
||||
user.IsPending = boolFromMap(resource, "isPending")
|
||||
user.IsSuspended = boolFromMap(resource, "isSuspended")
|
||||
user.IsDeleted = boolFromMap(resource, "isDeleted")
|
||||
primaryOrgUnit := parseWorksmobilePrimaryOrgUnitDetail(resource)
|
||||
user.PrimaryOrgUnitID = primaryOrgUnit.ID
|
||||
user.PrimaryOrgUnitName = primaryOrgUnit.Name
|
||||
@@ -1285,6 +1408,25 @@ func firstStringFromMap(values map[string]any, keys ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringListFromMap(values map[string]any, key string) []string {
|
||||
raw, ok := values[key].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
value, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func boolFromMap(values map[string]any, key string) bool {
|
||||
value, _ := values[key].(bool)
|
||||
return value
|
||||
@@ -1324,6 +1466,42 @@ func (c *WorksmobileHTTPClient) requestURL(path string) (string, error) {
|
||||
return strings.TrimRight(baseURL, "/") + path, nil
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) waitForWorksmobileAPI(ctx context.Context, method string, requestURL *url.URL) error {
|
||||
if c.RateLimiter == nil {
|
||||
return nil
|
||||
}
|
||||
return c.RateLimiter.Wait(ctx, worksmobileRateLimitKey(method, requestURL))
|
||||
}
|
||||
|
||||
func worksmobileRateLimitKey(method string, requestURL *url.URL) string {
|
||||
normalizedMethod := strings.ToUpper(strings.TrimSpace(method))
|
||||
if normalizedMethod == "" {
|
||||
normalizedMethod = "GET"
|
||||
}
|
||||
return normalizedMethod + " " + normalizeWorksmobileRateLimitPath(requestURL)
|
||||
}
|
||||
|
||||
func normalizeWorksmobileRateLimitPath(requestURL *url.URL) string {
|
||||
if requestURL == nil {
|
||||
return "/"
|
||||
}
|
||||
path := requestURL.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
segments := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(segments) == 1 && segments[0] == "" {
|
||||
return "/"
|
||||
}
|
||||
for i := 1; i < len(segments); i++ {
|
||||
switch strings.ToLower(segments[i-1]) {
|
||||
case "users", "orgunits", "groups", "alias-emails":
|
||||
segments[i] = "{id}"
|
||||
}
|
||||
}
|
||||
return "/" + strings.Join(segments, "/")
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
|
||||
Reference in New Issue
Block a user