package service import ( "bytes" "context" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const ( defaultWorksmobileAPIBaseURL = "https://www.worksapis.com" defaultWorksmobileOAuthTokenURL = "https://auth.worksmobile.com/oauth2/v2.0/token" defaultWorksmobileOAuthScope = "directory" ) type WorksmobileDirectoryClient interface { CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error CreateUser(ctx context.Context, payload WorksmobileUserPayload) error UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error DeleteUser(ctx context.Context, userID string) error ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) } type WorksmobileHTTPClient struct { BaseURL string DirectoryToken string SCIMToken string HTTPClient *http.Client OAuthConfig WorksmobileOAuthConfig DomainIDs []int64 tokenCache worksmobileAccessTokenCache now func() time.Time } type WorksmobileOAuthConfig struct { ClientID string ClientSecret string ServiceAccount string PrivateKey string Scope string TokenURL string } type worksmobileAccessTokenCache struct { Token string ExpiresAt time.Time } func (c WorksmobileOAuthConfig) normalized() WorksmobileOAuthConfig { c.ClientID = strings.Trim(strings.TrimSpace(c.ClientID), `"`) c.ClientSecret = strings.Trim(strings.TrimSpace(c.ClientSecret), `"`) c.ServiceAccount = strings.Trim(strings.TrimSpace(c.ServiceAccount), `"`) c.PrivateKey = normalizeWorksmobilePrivateKey(c.PrivateKey) c.Scope = strings.TrimSpace(c.Scope) if c.Scope == "" { c.Scope = defaultWorksmobileOAuthScope } c.TokenURL = strings.TrimSpace(c.TokenURL) if c.TokenURL == "" { c.TokenURL = defaultWorksmobileOAuthTokenURL } return c } func (c WorksmobileOAuthConfig) validate() error { if strings.TrimSpace(c.ClientID) == "" || strings.TrimSpace(c.ClientSecret) == "" { return fmt.Errorf("worksmobile directory token is not configured") } if strings.TrimSpace(c.ServiceAccount) == "" || strings.TrimSpace(c.PrivateKey) == "" { return fmt.Errorf("worksmobile oauth service account is not configured") } return nil } func normalizeWorksmobilePrivateKey(value string) string { value = strings.Trim(strings.TrimSpace(value), `"`) value = strings.ReplaceAll(value, `\n`, "\n") return value } func buildWorksmobileJWTAssertion(config WorksmobileOAuthConfig, now time.Time) (string, error) { privateKey, err := parseWorksmobilePrivateKey(config.PrivateKey) if err != nil { return "", err } header := map[string]string{"alg": "RS256", "typ": "JWT"} payload := map[string]any{ "iss": config.ClientID, "sub": config.ServiceAccount, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix(), } encodedHeader, err := encodeWorksmobileJWTPart(header) if err != nil { return "", err } encodedPayload, err := encodeWorksmobileJWTPart(payload) if err != nil { return "", err } signingInput := encodedHeader + "." + encodedPayload sum := sha256.Sum256([]byte(signingInput)) signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, sum[:]) if err != nil { return "", err } return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil } func encodeWorksmobileJWTPart(value any) (string, error) { data, err := json.Marshal(value) if err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(data), nil } func parseWorksmobilePrivateKey(value string) (*rsa.PrivateKey, error) { block, _ := pem.Decode([]byte(normalizeWorksmobilePrivateKey(value))) if block == nil { return nil, fmt.Errorf("worksmobile private key is not a valid PEM block") } if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { return key, nil } parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, err } key, ok := parsed.(*rsa.PrivateKey) if !ok { return nil, fmt.Errorf("worksmobile private key is not RSA") } return key, nil } type WorksmobileHTTPError struct { StatusCode int Body string } func (e WorksmobileHTTPError) Error() string { return fmt.Sprintf("worksmobile api failed status=%d body=%s", e.StatusCode, e.Body) } func NewWorksmobileHTTPClient(scimToken string) *WorksmobileHTTPClient { return &WorksmobileHTTPClient{ BaseURL: defaultWorksmobileAPIBaseURL, SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`), } } func NewWorksmobileHTTPClientWithTokens(directoryToken string, scimToken string) *WorksmobileHTTPClient { return &WorksmobileHTTPClient{ BaseURL: defaultWorksmobileAPIBaseURL, DirectoryToken: strings.Trim(strings.TrimSpace(directoryToken), `"`), SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`), } } func NewWorksmobileHTTPClientWithAuth(directoryToken string, scimToken string, oauthConfig WorksmobileOAuthConfig) *WorksmobileHTTPClient { return &WorksmobileHTTPClient{ BaseURL: defaultWorksmobileAPIBaseURL, DirectoryToken: strings.Trim(strings.TrimSpace(directoryToken), `"`), SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`), OAuthConfig: oauthConfig.normalized(), DomainIDs: WorksmobileDomainIDsFromEnv(), } } func (c *WorksmobileHTTPClient) CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error { return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload) } func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error { return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload) } func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error { err := c.CreateUser(ctx, payload) if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict { identifier := strings.TrimSpace(payload.Email) if identifier == "" { identifier = strings.TrimSpace(payload.UserExternalKey) } return c.PatchUser(ctx, identifier, NewWorksmobileUserPatchPayload(payload)) } return err } func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error { identifier = strings.TrimSpace(identifier) if identifier == "" { return fmt.Errorf("worksmobile user identifier is required") } return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), payload) } func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) error { userID = strings.TrimSpace(userID) if userID == "" { return fmt.Errorf("worksmobile user id is required") } remote, err := c.FindUser(ctx, userID) if err != nil { return err } if remote == nil { return nil } if c.directoryAuthConfigured() && remote.Email != "" { err := c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/users/"+url.PathEscape(remote.Email), nil) if err == nil || strings.TrimSpace(c.SCIMToken) == "" { return err } } return c.sendJSON(ctx, http.MethodDelete, "/scim/v2/Users/"+url.PathEscape(remote.ID), nil) } func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) { users, err := c.ListUsers(ctx) if err != nil { return nil, err } identifier = strings.TrimSpace(identifier) for _, user := range users { if strings.EqualFold(user.UserName, identifier) || user.ExternalID == identifier || strings.EqualFold(user.Email, identifier) { return &user, nil } } return nil, nil } func (c *WorksmobileHTTPClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) { if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 { users, err := c.listDirectoryUsers(ctx, c.DomainIDs) if err == nil { return users, nil } if strings.TrimSpace(c.SCIMToken) == "" { return nil, err } } var users []WorksmobileRemoteUser err := c.listSCIM(ctx, "/scim/v2/Users", func(resource map[string]any) { users = append(users, parseWorksmobileRemoteUser(resource)) }) return users, err } func (c *WorksmobileHTTPClient) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) { if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 { groups, err := c.listDirectoryGroups(ctx, c.DomainIDs) if err == nil { return groups, nil } if strings.TrimSpace(c.SCIMToken) == "" { return nil, err } } var groups []WorksmobileRemoteGroup err := c.listSCIM(ctx, "/scim/v2/Groups", func(resource map[string]any) { groups = append(groups, parseWorksmobileRemoteGroup(resource)) }) return groups, err } func (c *WorksmobileHTTPClient) listSCIM(ctx context.Context, path string, consume func(map[string]any)) error { startIndex := 1 count := 100 for { var response struct { TotalResults int `json:"totalResults"` ItemsPerPage int `json:"itemsPerPage"` Resources []map[string]any `json:"Resources"` } if err := c.getJSON(ctx, fmt.Sprintf("%s?startIndex=%d&count=%d", path, startIndex, count), &response); err != nil { return err } for _, resource := range response.Resources { consume(resource) } if len(response.Resources) == 0 || startIndex+len(response.Resources) > response.TotalResults { return nil } startIndex += len(response.Resources) } } func (c *WorksmobileHTTPClient) getJSON(ctx context.Context, path string, target any) error { token := strings.TrimSpace(c.SCIMToken) if token == "" { token = strings.TrimSpace(c.DirectoryToken) } if token == "" { return fmt.Errorf("worksmobile read token is not configured") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.baseURL(), "/")+path, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") resp, err := c.httpClient().Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)} } return json.NewDecoder(resp.Body).Decode(target) } func (c *WorksmobileHTTPClient) getDirectoryJSON(ctx context.Context, path string, target any) error { token, err := c.directoryAccessToken(ctx) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.baseURL(), "/")+path, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") resp, err := c.httpClient().Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)} } return json.NewDecoder(resp.Body).Decode(target) } func (c *WorksmobileHTTPClient) listDirectoryUsers(ctx context.Context, domainIDs []int64) ([]WorksmobileRemoteUser, error) { users := make([]WorksmobileRemoteUser, 0) for _, domainID := range uniqueWorksmobileDomainIDs(domainIDs) { cursor := "" for { path := fmt.Sprintf("/v1.0/users?domainId=%d&count=100", domainID) if cursor != "" { path += "&cursor=" + url.QueryEscape(cursor) } var response struct { Users []map[string]any `json:"users"` ResponseMetaData struct { NextCursor string `json:"nextCursor"` } `json:"responseMetaData"` } if err := c.getDirectoryJSON(ctx, path, &response); err != nil { return nil, err } for _, raw := range response.Users { user := parseWorksmobileDirectoryUser(raw) user.DomainID = domainID user.DomainName = WorksmobileDomainLabelForID(domainID) users = append(users, user) } cursor = strings.TrimSpace(response.ResponseMetaData.NextCursor) if cursor == "" { break } } } return users, nil } func (c *WorksmobileHTTPClient) listDirectoryGroups(ctx context.Context, domainIDs []int64) ([]WorksmobileRemoteGroup, error) { groups := make([]WorksmobileRemoteGroup, 0) for _, domainID := range uniqueWorksmobileDomainIDs(domainIDs) { cursor := "" for { path := fmt.Sprintf("/v1.0/orgunits?domainId=%d&count=100", domainID) if cursor != "" { path += "&cursor=" + url.QueryEscape(cursor) } var response struct { OrgUnits []map[string]any `json:"orgUnits"` ResponseMetaData struct { NextCursor string `json:"nextCursor"` } `json:"responseMetaData"` } if err := c.getDirectoryJSON(ctx, path, &response); err != nil { return nil, err } for _, raw := range response.OrgUnits { group := parseWorksmobileDirectoryGroup(raw) group.DomainID = domainID group.DomainName = WorksmobileDomainLabelForID(domainID) groups = append(groups, group) } cursor = strings.TrimSpace(response.ResponseMetaData.NextCursor) if cursor == "" { break } } } return groups, nil } func uniqueWorksmobileDomainIDs(domainIDs []int64) []int64 { result := make([]int64, 0, len(domainIDs)) seen := map[int64]bool{} for _, id := range domainIDs { if id <= 0 || seen[id] { continue } seen[id] = true result = append(result, id) } return result } func (c *WorksmobileHTTPClient) sendJSON(ctx context.Context, method string, path string, payload any) error { token := strings.TrimSpace(c.SCIMToken) if token == "" { return fmt.Errorf("worksmobile scim token is not configured") } return c.sendJSONWithToken(ctx, method, path, payload, token) } func (c *WorksmobileHTTPClient) sendDirectoryJSON(ctx context.Context, method string, path string, payload any) error { token, err := c.directoryAccessToken(ctx) if err != nil { return err } return c.sendJSONWithToken(ctx, method, path, payload, token) } func (c *WorksmobileHTTPClient) directoryAccessToken(ctx context.Context) (string, error) { if token := strings.TrimSpace(c.DirectoryToken); token != "" { return token, nil } now := c.currentTime() if c.tokenCache.Token != "" && now.Before(c.tokenCache.ExpiresAt) { return c.tokenCache.Token, nil } token, expiresAt, err := c.requestDirectoryAccessToken(ctx, now) if err != nil { return "", err } c.tokenCache = worksmobileAccessTokenCache{Token: token, ExpiresAt: expiresAt} return token, nil } func (c *WorksmobileHTTPClient) directoryAuthConfigured() bool { if strings.TrimSpace(c.DirectoryToken) != "" { return true } return c.OAuthConfig.normalized().validate() == nil } func (c *WorksmobileHTTPClient) requestDirectoryAccessToken(ctx context.Context, now time.Time) (string, time.Time, error) { config := c.OAuthConfig.normalized() if err := config.validate(); err != nil { return "", time.Time{}, err } assertion, err := buildWorksmobileJWTAssertion(config, now) if err != nil { return "", time.Time{}, err } form := url.Values{} form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") form.Set("assertion", assertion) form.Set("client_id", config.ClientID) form.Set("client_secret", config.ClientSecret) form.Set("scope", config.Scope) req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.TokenURL, strings.NewReader(form.Encode())) if err != nil { return "", time.Time{}, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := c.httpClient().Do(req) if err != nil { return "", time.Time{}, err } defer resp.Body.Close() if resp.StatusCode >= 300 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return "", time.Time{}, WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)} } var tokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn any `json:"expires_in"` TokenType string `json:"token_type"` } if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { return "", time.Time{}, err } if strings.TrimSpace(tokenResponse.AccessToken) == "" { return "", time.Time{}, fmt.Errorf("worksmobile token response is missing access_token") } expiresIn := worksmobileTokenExpiresIn(tokenResponse.ExpiresIn) if expiresIn <= 0 { expiresIn = 3600 } return strings.TrimSpace(tokenResponse.AccessToken), now.Add(time.Duration(expiresIn-60) * time.Second), nil } func worksmobileTokenExpiresIn(raw any) int64 { switch value := raw.(type) { case float64: return int64(value) case int64: return value case string: parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64) return parsed default: return 0 } } func (c *WorksmobileHTTPClient) sendJSONWithToken(ctx context.Context, method string, path string, payload any, token string) error { var body io.Reader if payload != nil { data, err := json.Marshal(payload) if err != nil { return err } body = bytes.NewReader(data) } req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.baseURL(), "/")+path, body) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(token)) if payload != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.httpClient().Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil } data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return WorksmobileHTTPError{StatusCode: resp.StatusCode, Body: string(data)} } const worksmobileSCIMUserExtensionSchema = "urn:ietf:params:scim:schemas:extension:works:2.0:User" type WorksmobileSCIMUserPayload struct { Schemas []string `json:"schemas"` UserName string `json:"userName"` ExternalID string `json:"externalId"` DisplayName string `json:"displayName"` Name WorksmobileSCIMName `json:"name"` Emails []WorksmobileSCIMEmail `json:"emails"` PhoneNumbers []WorksmobileSCIMPhoneNumber `json:"phoneNumbers,omitempty"` Password string `json:"password,omitempty"` Active bool `json:"active"` PreferredLanguage string `json:"preferredLanguage,omitempty"` WorksExtension map[string]any `json:"urn:ietf:params:scim:schemas:extension:works:2.0:User,omitempty"` } type WorksmobileSCIMName struct { FamilyName string `json:"familyName"` } type WorksmobileSCIMEmail struct { Value string `json:"value"` Primary bool `json:"primary"` Type string `json:"type,omitempty"` } type WorksmobileSCIMPhoneNumber struct { Value string `json:"value"` Primary bool `json:"primary"` Type string `json:"type,omitempty"` } type WorksmobileUserPatchPayload struct { DomainID int64 `json:"domainId"` Email string `json:"email,omitempty"` UserExternalKey string `json:"userExternalKey,omitempty"` UserName WorksmobileUserName `json:"userName,omitempty"` CellPhone string `json:"cellPhone,omitempty"` EmployeeNumber string `json:"employeeNumber,omitempty"` AliasEmails []string `json:"aliasEmails,omitempty"` Locale string `json:"locale,omitempty"` Task string `json:"task,omitempty"` Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"` } type WorksmobileRemoteUser struct { ID string `json:"id"` ExternalID string `json:"externalId"` UserName string `json:"userName"` Email string `json:"email"` DisplayName string `json:"displayName"` LevelID string `json:"levelId"` LevelName string `json:"levelName"` Task string `json:"task"` DomainID int64 `json:"domainId"` DomainName string `json:"domainName"` PrimaryOrgUnitID string `json:"primaryOrgUnitId"` PrimaryOrgUnitName string `json:"primaryOrgUnitName"` PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"` PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"` PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"` Active bool `json:"active"` } type WorksmobileRemoteGroup struct { ID string `json:"id"` ExternalID string `json:"externalId"` DisplayName string `json:"displayName"` DomainID int64 `json:"domainId"` DomainName string `json:"domainName"` ParentID string `json:"parentId"` ParentName string `json:"parentName"` } func NewWorksmobileSCIMUserPayload(payload WorksmobileUserPayload) WorksmobileSCIMUserPayload { name := strings.TrimSpace(payload.UserName.LastName) if name == "" { name = strings.TrimSpace(payload.Email) } result := WorksmobileSCIMUserPayload{ Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:User", worksmobileSCIMUserExtensionSchema}, UserName: strings.TrimSpace(payload.Email), ExternalID: strings.TrimSpace(payload.UserExternalKey), DisplayName: name, Name: WorksmobileSCIMName{FamilyName: name}, Emails: []WorksmobileSCIMEmail{{Value: strings.TrimSpace(payload.Email), Primary: true, Type: "other"}}, Password: payload.PasswordConfig.Password, Active: true, PreferredLanguage: worksmobileSCIMPreferredLanguage(payload.Locale), WorksExtension: map[string]any{ "employeeNumber": payload.EmployeeNumber, "task": payload.Task, }, } if strings.TrimSpace(payload.CellPhone) != "" { result.PhoneNumbers = []WorksmobileSCIMPhoneNumber{{Value: strings.TrimSpace(payload.CellPhone), Primary: true, Type: "mobile"}} } return result } 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), EmployeeNumber: strings.TrimSpace(payload.EmployeeNumber), AliasEmails: payload.AliasEmails, Locale: strings.TrimSpace(payload.Locale), Task: strings.TrimSpace(payload.Task), Organizations: payload.Organizations, } } func worksmobileSCIMPreferredLanguage(locale string) string { locale = strings.TrimSpace(locale) if locale == "" { return "" } return strings.ReplaceAll(locale, "_", "-") } func parseWorksmobileRemoteUser(resource map[string]any) WorksmobileRemoteUser { user := WorksmobileRemoteUser{ ID: stringFromMap(resource, "id"), ExternalID: stringFromMap(resource, "externalId"), UserName: stringFromMap(resource, "userName"), DisplayName: stringFromMap(resource, "displayName"), Active: boolFromMap(resource, "active"), } if emails, ok := resource["emails"].([]any); ok { for _, raw := range emails { email, ok := raw.(map[string]any) if !ok { continue } if user.Email == "" || boolFromMap(email, "primary") { user.Email = stringFromMap(email, "value") } } } if user.Email == "" && strings.Contains(user.UserName, "@") { user.Email = user.UserName } user.PrimaryOrgUnitID, user.PrimaryOrgUnitName = parseWorksmobilePrimaryOrgUnit(resource) return user } func parseWorksmobileRemoteGroup(resource map[string]any) WorksmobileRemoteGroup { group := WorksmobileRemoteGroup{ ID: stringFromMap(resource, "id"), ExternalID: stringFromMap(resource, "externalId"), DisplayName: stringFromMap(resource, "displayName"), } group.ParentID, group.ParentName = parseWorksmobileParentOrgUnit(resource) return group } func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUser { email := firstStringFromMap(resource, "email", "loginId", "userName") user := WorksmobileRemoteUser{ ID: firstStringFromMap(resource, "userId", "id"), ExternalID: firstStringFromMap(resource, "userExternalKey", "externalKey", "externalId"), UserName: email, Email: email, DisplayName: parseWorksmobileDirectoryUserName(resource), LevelID: parseWorksmobileUserLevelID(resource), LevelName: parseWorksmobileUserLevelName(resource), Task: firstStringFromMap(resource, "task", "job", "jobDescription"), Active: true, } if active, ok := resource["active"].(bool); ok { user.Active = active } primaryOrgUnit := parseWorksmobilePrimaryOrgUnitDetail(resource) user.PrimaryOrgUnitID = primaryOrgUnit.ID user.PrimaryOrgUnitName = primaryOrgUnit.Name user.PrimaryOrgUnitPositionID = primaryOrgUnit.PositionID user.PrimaryOrgUnitPositionName = primaryOrgUnit.PositionName user.PrimaryOrgUnitIsManager = primaryOrgUnit.IsManager return user } func parseWorksmobileDirectoryGroup(resource map[string]any) WorksmobileRemoteGroup { return WorksmobileRemoteGroup{ ID: firstStringFromMap(resource, "orgUnitId", "id"), ExternalID: firstStringFromMap(resource, "orgUnitExternalKey", "externalKey", "externalId"), DisplayName: firstStringFromMap(resource, "orgUnitName", "displayName", "name"), ParentID: firstStringFromMap(resource, "parentOrgUnitId", "parentId"), ParentName: firstStringFromMap(resource, "parentOrgUnitName", "parentName"), } } func parseWorksmobileDirectoryUserName(resource map[string]any) string { if value := firstStringFromMap(resource, "displayName", "name"); value != "" { return value } if name, ok := resource["userName"].(map[string]any); ok { if value := firstStringFromMap(name, "fullName", "displayName", "name"); value != "" { return value } if value := joinWorksmobileNameParts(firstStringFromMap(name, "lastName", "familyName"), firstStringFromMap(name, "firstName", "givenName")); value != "" { return value } } if name, ok := resource["name"].(map[string]any); ok { if value := firstStringFromMap(name, "fullName", "displayName", "name"); value != "" { return value } if value := joinWorksmobileNameParts(firstStringFromMap(name, "lastName", "familyName"), firstStringFromMap(name, "firstName", "givenName")); value != "" { return value } } return "" } func joinWorksmobileNameParts(lastName, firstName string) string { lastName = strings.TrimSpace(lastName) firstName = strings.TrimSpace(firstName) if lastName == "" { return firstName } if firstName == "" { return lastName } return lastName + firstName } func parseWorksmobileUserLevelID(resource map[string]any) string { if value := firstStringFromMap(resource, "levelId"); value != "" { return value } if level, ok := resource["level"].(map[string]any); ok { return firstStringFromMap(level, "levelId", "id", "value") } return "" } func parseWorksmobileUserLevelName(resource map[string]any) string { if value := firstStringFromMap(resource, "levelName"); value != "" { return value } if level, ok := resource["level"].(map[string]any); ok { return firstStringFromMap(level, "levelName", "displayName", "name") } return "" } type worksmobileOrgUnitDetail struct { ID string Name string PositionID string PositionName string IsManager *bool } func (d worksmobileOrgUnitDetail) empty() bool { return d.ID == "" && d.Name == "" && d.PositionID == "" && d.PositionName == "" && d.IsManager == nil } func parseWorksmobilePrimaryOrgUnit(resource map[string]any) (string, string) { detail := parseWorksmobilePrimaryOrgUnitDetail(resource) return detail.ID, detail.Name } func parseWorksmobilePrimaryOrgUnitDetail(resource map[string]any) worksmobileOrgUnitDetail { if detail := parseWorksmobileOrgUnitDetailList(resource["organizations"], true); !detail.empty() { return detail } if detail := parseWorksmobileOrgUnitDetailList(resource["orgUnits"], true); !detail.empty() { return detail } for key, raw := range resource { if !strings.Contains(strings.ToLower(key), "works") { continue } if values, ok := raw.(map[string]any); ok { if detail := parseWorksmobileOrgUnitDetailList(values["organizations"], true); !detail.empty() { return detail } if detail := parseWorksmobileOrgUnitDetailList(values["orgUnits"], true); !detail.empty() { return detail } } } return worksmobileOrgUnitDetail{} } func parseWorksmobileParentOrgUnit(resource map[string]any) (string, string) { id := firstStringFromMap(resource, "parentOrgUnitId", "parentId") name := firstStringFromMap(resource, "parentOrgUnitName", "parentName") if id != "" || name != "" { return id, name } for _, key := range []string{"parent", "parentOrgUnit"} { if values, ok := resource[key].(map[string]any); ok { id = firstStringFromMap(values, "id", "orgUnitId", "value") name = firstStringFromMap(values, "displayName", "orgUnitName", "name") if id != "" || name != "" { return id, name } } } for key, raw := range resource { if !strings.Contains(strings.ToLower(key), "works") { continue } if values, ok := raw.(map[string]any); ok { if id, name := parseWorksmobileParentOrgUnit(values); id != "" || name != "" { return id, name } } } return "", "" } func parseWorksmobileOrgUnitList(raw any, preferPrimary bool) (string, string) { detail := parseWorksmobileOrgUnitDetailList(raw, preferPrimary) return detail.ID, detail.Name } func parseWorksmobileOrgUnitDetailList(raw any, preferPrimary bool) worksmobileOrgUnitDetail { values, ok := raw.([]any) if !ok { return worksmobileOrgUnitDetail{} } var fallback worksmobileOrgUnitDetail for _, item := range values { orgUnit, ok := item.(map[string]any) if !ok { continue } detail := worksmobileOrgUnitDetail{ ID: firstStringFromMap(orgUnit, "orgUnitId", "id", "value"), Name: firstStringFromMap(orgUnit, "orgUnitName", "displayName", "name"), PositionID: firstStringFromMap(orgUnit, "positionId"), PositionName: firstStringFromMap(orgUnit, "positionName"), IsManager: boolPointerFromMap(orgUnit, "isManager", "manager"), } if detail.empty() { if nested := parseWorksmobileOrgUnitDetailList(orgUnit["orgUnits"], preferPrimary); !nested.empty() { detail = nested } } if fallback.empty() { fallback = detail } if !preferPrimary || boolFromMap(orgUnit, "primary") { return detail } } return fallback } func stringFromMap(values map[string]any, key string) string { value, _ := values[key].(string) return strings.TrimSpace(value) } func firstStringFromMap(values map[string]any, keys ...string) string { for _, key := range keys { if value := stringFromMap(values, key); value != "" { return value } } return "" } func boolFromMap(values map[string]any, key string) bool { value, _ := values[key].(bool) return value } func boolPointerFromMap(values map[string]any, keys ...string) *bool { for _, key := range keys { if value, ok := values[key].(bool); ok { return &value } } return nil } func (c *WorksmobileHTTPClient) baseURL() string { if strings.TrimSpace(c.BaseURL) == "" { return defaultWorksmobileAPIBaseURL } return c.BaseURL } func (c *WorksmobileHTTPClient) httpClient() *http.Client { if c.HTTPClient != nil { return c.HTTPClient } return &http.Client{Timeout: 15 * time.Second} } func (c *WorksmobileHTTPClient) currentTime() time.Time { if c.now != nil { return c.now() } return time.Now() }