1
0
forked from baron/baron-sso

chore: consolidate local integration changes

This commit is contained in:
2026-06-09 21:03:05 +09:00
parent aa2848c3b6
commit 1341f07ef9
158 changed files with 10995 additions and 1490 deletions

View File

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