1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/service/worksmobile_client.go

970 lines
31 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 defaultWorksmobileAPIBaseURL = "https://www.worksapis.com"
const defaultWorksmobileOAuthTokenURL = "https://auth.worksmobile.com/oauth2/v2.0/token"
const 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()
}