1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/service/worksmobile_client.go
chan 31d107ff2e feat(user): support fixed UUID registration and enhance bulk import results
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping)
- Implemented idempotency and visibility restoration for soft-deleted users
- Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields
- Added logic to reclaim identifiers (login_id) from colliding records
- Added frontend E2E and backend unit tests for UUID integrity and conflict handling
- Fixed i18n, formatting, and mock tests to satisfy code-check
- Applied 'go fix' for 'omitzero' tags and general Go standards
2026-06-01 15:34:08 +09:00

1161 lines
38 KiB
Go

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
}