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 ( defaultWorksmobileOAuthScope = "directory" ) type WorksmobileDirectoryClient interface { CreateOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload) error UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error DeleteOrgUnit(ctx context.Context, orgUnitID string) error CreateUser(ctx context.Context, payload WorksmobileUserPayload) error UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error DeleteUser(ctx context.Context, userID string) error SetUserActive(ctx context.Context, userID string, active bool) 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 OrgUnitWriteDelay time.Duration 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) 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") } if strings.TrimSpace(c.TokenURL) == "" { return fmt.Errorf("worksmobile oauth token url 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{ SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`), } } func NewWorksmobileHTTPClientWithTokens(directoryToken string, scimToken string) *WorksmobileHTTPClient { return &WorksmobileHTTPClient{ DirectoryToken: strings.Trim(strings.TrimSpace(directoryToken), `"`), SCIMToken: strings.Trim(strings.TrimSpace(scimToken), `"`), } } func NewWorksmobileHTTPClientWithAuth(directoryToken string, scimToken string, oauthConfig WorksmobileOAuthConfig) *WorksmobileHTTPClient { return &WorksmobileHTTPClient{ 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 { if payload.DisplayOrder < 1 { payload.DisplayOrder = 1 } return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/orgunits", payload) } func (c *WorksmobileHTTPClient) UpsertOrgUnit(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error { err := c.CreateOrgUnit(ctx, payload) if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict { return c.BackfillOrgUnitExternalKeyByLocalPart(ctx, payload, matchLocalPart) } return err } func (c *WorksmobileHTTPClient) BackfillOrgUnitExternalKeyByLocalPart(ctx context.Context, payload WorksmobileOrgUnitPayload, matchLocalPart string) error { groups, err := c.ListGroups(ctx) if err != nil { return err } normalizedMatchLocalPart := worksmobileMailLocalPart(matchLocalPart) var localPartMatch *WorksmobileRemoteGroup for _, group := range groups { if payload.DomainID > 0 && group.DomainID > 0 && payload.DomainID != group.DomainID { continue } if group.ExternalID == payload.OrgUnitExternalKey { if strings.TrimSpace(group.ID) == "" { return nil } if delay := c.orgUnitWriteDelay(); delay > 0 { time.Sleep(delay) } return c.PatchOrgUnit(ctx, group.ID, NewWorksmobileOrgUnitPatchPayload(payload)) } if normalizedMatchLocalPart != "" && worksmobileMailLocalPart(group.MailLocalPart) == normalizedMatchLocalPart { matched := group if localPartMatch != nil && localPartMatch.ID != matched.ID { return fmt.Errorf("worksmobile orgunit local-part match is ambiguous: %s", normalizedMatchLocalPart) } localPartMatch = &matched } } if localPartMatch != nil { if strings.TrimSpace(localPartMatch.ID) == "" { return nil } if delay := c.orgUnitWriteDelay(); delay > 0 { time.Sleep(delay) } return c.PatchOrgUnit(ctx, localPartMatch.ID, NewWorksmobileOrgUnitPatchPayload(payload)) } return fmt.Errorf("worksmobile orgunit external key match not found after create conflict: %s", payload.OrgUnitExternalKey) } func (c *WorksmobileHTTPClient) PatchOrgUnit(ctx context.Context, orgUnitID string, payload WorksmobileOrgUnitPatchPayload) error { orgUnitID = strings.TrimSpace(orgUnitID) if orgUnitID == "" { return fmt.Errorf("worksmobile orgunit id is required") } return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), payload) } func (c *WorksmobileHTTPClient) ClearOrgUnitExternalKey(ctx context.Context, orgUnitID string, domainID int64) error { orgUnitID = strings.TrimSpace(orgUnitID) if orgUnitID == "" { return fmt.Errorf("worksmobile orgunit id is required") } payload := map[string]any{ "domainId": domainID, "orgUnitExternalKey": "", } return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), payload) } func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID string) error { orgUnitID = strings.TrimSpace(orgUnitID) if orgUnitID == "" { return fmt.Errorf("worksmobile orgunit id is required") } if delay := c.orgUnitWriteDelay(); delay > 0 { time.Sleep(delay) } return c.sendDirectoryJSON(ctx, http.MethodDelete, "/v1.0/orgunits/"+url.PathEscape(orgUnitID), nil) } 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) SetUserActive(ctx context.Context, userID string, active bool) error { userID = strings.TrimSpace(userID) if userID == "" { return fmt.Errorf("worksmobile user id is required") } if strings.TrimSpace(c.SCIMToken) == "" { return fmt.Errorf("worksmobile scim token is not configured") } remote, err := c.findSCIMUser(ctx, userID) if err != nil { return err } if remote == nil { return nil } return c.sendJSON(ctx, http.MethodPatch, "/scim/v2/Users/"+url.PathEscape(remote.ID), map[string]any{ "schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, "Operations": []map[string]any{ { "op": "replace", "path": "active", "value": active, }, }, }) } 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) findSCIMUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) { identifier = strings.TrimSpace(identifier) var matched *WorksmobileRemoteUser err := c.listSCIM(ctx, "/scim/v2/Users", func(resource map[string]any) { if matched != nil { return } user := parseWorksmobileRemoteUser(resource) if strings.EqualFold(user.UserName, identifier) || user.ExternalID == identifier || strings.EqualFold(user.Email, identifier) { matched = &user } }) return matched, err } 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") } requestURL, err := c.requestURL(path) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, 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 } requestURL, err := c.requestURL(path) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, 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) } requestURL, err := c.requestURL(path) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, method, requestURL, 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"` 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 WorksmobileOrgUnitPatchPayload struct { DomainID int64 `json:"domainId"` Email string `json:"email,omitempty"` OrgUnitName string `json:"orgUnitName,omitempty"` OrgUnitExternalKey string `json:"orgUnitExternalKey,omitempty"` ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"` DisplayOrder int `json:"displayOrder,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"` Email string `json:"email,omitempty"` MailLocalPart string `json:"mailLocalPart,omitempty"` 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 NewWorksmobileOrgUnitPatchPayload(payload WorksmobileOrgUnitPayload) WorksmobileOrgUnitPatchPayload { return WorksmobileOrgUnitPatchPayload{ DomainID: payload.DomainID, Email: strings.TrimSpace(payload.Email), OrgUnitName: strings.TrimSpace(payload.OrgUnitName), OrgUnitExternalKey: strings.TrimSpace(payload.OrgUnitExternalKey), ParentOrgUnitID: strings.TrimSpace(payload.ParentOrgUnitID), DisplayOrder: payload.DisplayOrder, } } 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 { email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName") group := WorksmobileRemoteGroup{ ID: stringFromMap(resource, "id"), ExternalID: stringFromMap(resource, "externalId"), DisplayName: stringFromMap(resource, "displayName"), Email: email, MailLocalPart: worksmobileMailLocalPart(email), } 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 { email := firstStringFromMap(resource, "email", "mail", "groupEmail", "mailingList", "orgUnitEmail", "loginId", "userName") return WorksmobileRemoteGroup{ ID: firstStringFromMap(resource, "orgUnitId", "id"), ExternalID: firstStringFromMap(resource, "orgUnitExternalKey", "externalKey", "externalId"), DisplayName: firstStringFromMap(resource, "orgUnitName", "displayName", "name"), Email: email, MailLocalPart: worksmobileMailLocalPart(email), ParentID: firstStringFromMap(resource, "parentOrgUnitId", "parentId"), ParentName: firstStringFromMap(resource, "parentOrgUnitName", "parentName"), } } func worksmobileMailLocalPart(value string) string { normalized := strings.ToLower(strings.TrimSpace(value)) if normalized == "" { return "" } if at := strings.Index(normalized, "@"); at >= 0 { normalized = normalized[:at] } return strings.TrimSpace(normalized) } 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 { return c.BaseURL } func (c *WorksmobileHTTPClient) requestURL(path string) (string, error) { baseURL := strings.TrimSpace(c.baseURL()) if baseURL == "" { return "", fmt.Errorf("worksmobile api base url is not configured") } return strings.TrimRight(baseURL, "/") + path, nil } 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() } func (c *WorksmobileHTTPClient) orgUnitWriteDelay() time.Duration { if c.OrgUnitWriteDelay < 0 { return 0 } if c.OrgUnitWriteDelay > 0 { return c.OrgUnitWriteDelay } return 1100 * time.Millisecond }