* feat(auth): support multi-profile login
* fix(auth): complete multi-org profile acceptance
* feat(auth): 完成多组织 profile 验收
* docs(auth): 补充多组织 Ralph 验收材料
* feat(auth): 支持 auth switch TUI 切换 profile
* feat(auth): logout 默认清理所有组织
* feat(auth): login 默认新增组织授权
* feat(profile): 使用 profile switch 切换组织
* docs(ralph): 更新 profile switch 验收材料
* fix(profile): 展示全部可切换组织
* feat(profile): support multi-org switch tui
* chore(install): add branch source installer
* fix(profile): keep global profile out of tool params
* feat(profile): support csv multi-profile runtime
* ci: add multi-profile e2e workflow
* ci: run multi-profile e2e on all branches
* docs: document multi-profile e2e ci gate
* docs: remove multi-profile test cases from pr
* ci: harden multi-profile e2e gates
* fix(auth): serialize profiles.json RMW and harden multi-profile persistence
Wrap all profiles.json read-modify-write paths (profile switch/use/remove,
status marking, token save, logout) in the existing dual-layer lock via a new
withProfilesLock helper. Split each writer into a public (locking) entry point
plus a lock-free *Locked variant so the non-reentrant lock is never re-acquired;
the refresh path (oauth_helpers) and the load-path legacy migration now call the
lock-free saver to avoid self-deadlock.
Also: write profiles.json and the token marker via per-write random temp names
(uuid) to stop concurrent writers from corrupting a fixed .tmp file; quarantine
an unparseable profiles.json and rebuild an empty config so the CLI can
self-heal instead of locking out auth reset/logout; make DeleteAllTokenData
proceed even if profiles.json cannot be read; and stop SyncLegacyTokenMirror
from deleting the legacy mirror on a transient keychain read error.
* fix(auth): do not fall back to a different org's legacy token slot
When no explicit --profile is given, LoadTokenDataForProfile resolves the
current/primary profile and reads its per-corp keychain slot. If that slot
read failed, the code silently fell through to the legacy single token slot,
which after any drift between the legacy mirror and the current profile could
belong to a different organization. The command would then run as the wrong
org with no indication to the user.
Reproduction (conceptual):
- profiles.json currentProfile = corpA
- corpA's keychain slot is unreadable, legacy single slot still holds corpB
- any read command (no --profile) silently used corpB's token
Fix: when a profile is resolved but its slot read fails and no --profile was
given, only fall back to the legacy single slot when its CorpID matches the
resolved profile (same org); otherwise return the original error instead of
acting as a different organization. The no-profile legacy path (pre-migration
installs with no resolved profile) is unchanged.
Tests:
- Covered by the existing internal/auth suite under go test -race; the
same-org fallback preserves the legacy-mirror case while the cross-org
case now surfaces the read error.
* feat(skill): document multi-org profile usage and always ship dws-shared
The skills had no guidance on the multi-profile capability, so an agent would
treat the CLI as single-org: when a lookup missed in the current org it would
give up or ask the user instead of searching other logged-in orgs. The multi
skill set also referenced a `dws-shared` prerequisite that was never actually
installed, and the only multi-org hints lived inline in three product skills.
This adds, in source only:
- A "multi-org / profile" section in the mono SKILL.md (concept, commands,
cross-org rule, aggregation, safety guardrails) plus a decision-tree entry,
trigger conditions, and a corrected logout danger-table row (logout removes
all orgs by default; removing the primary silently re-elects a new primary,
confirm before removing the primary).
- A standalone skills/multi/dingtalk-profile skill mirroring the same content.
- A new skills/multi/dws-shared skill that carries auth, global flags and the
multi-org rule, so every product skill's PREREQUISITE resolves and all
read/search skills inherit the cross-org behavior without per-skill edits.
- Cross-org fallback notes on dingtalk-aisearch / chat / contact.
To guarantee the prerequisite actually ships, multi-mode install now force-
includes dws-shared even when --skill / --exclude narrows the set (no-op when
the source has no dws-shared, preserving older layouts).
Tests:
- internal/app: TestP1SharedAlwaysIncludedWithSkillFilter installs with
`-s aitable` and asserts dws-shared still lands in the destination;
TestP1SharedNoopWhenAbsent guards the older-layout no-op.
- go test -race ./internal/auth/... ./internal/app/... passes.
---------
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: qinze <audanye@gmail.com>
679 lines
19 KiB
Go
679 lines
19 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
// withProfilesLock runs fn while holding the auth dual-layer lock (process +
|
|
// cross-process file lock) so that all read-modify-write cycles on
|
|
// profiles.json and the legacy token mirror are serialized.
|
|
//
|
|
// The lock is NOT reentrant. fn must only call the lock-free *Locked variants;
|
|
// calling a public (locking) function from within fn would deadlock. Paths that
|
|
// already hold the lock (e.g. OAuthProvider.lockedRefresh and the read path
|
|
// reached from it) must likewise call the lock-free variants directly.
|
|
func withProfilesLock(configDir string, fn func() error) error {
|
|
lock, err := AcquireDualLock(context.Background(), configDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer lock.Release()
|
|
return fn()
|
|
}
|
|
|
|
const profilesJSONFile = "profiles.json"
|
|
|
|
const (
|
|
ProfileStatusActive = "active"
|
|
ProfileStatusExpired = "expired"
|
|
ProfileStatusRevoked = "revoked"
|
|
)
|
|
|
|
// ProfilesConfig stores non-sensitive profile metadata. Token material stays in keychain.
|
|
type ProfilesConfig struct {
|
|
Version int `json:"version"`
|
|
PrimaryProfile string `json:"primaryProfile,omitempty"`
|
|
CurrentProfile string `json:"currentProfile,omitempty"`
|
|
PreviousProfile string `json:"previousProfile,omitempty"`
|
|
Profiles []Profile `json:"profiles,omitempty"`
|
|
}
|
|
|
|
// Profile is a logged-in DingTalk organization identity.
|
|
type Profile struct {
|
|
Name string `json:"name"`
|
|
CorpID string `json:"corpId"`
|
|
CorpName string `json:"corpName,omitempty"`
|
|
UserID string `json:"userId,omitempty"`
|
|
UserName string `json:"userName,omitempty"`
|
|
ClientID string `json:"clientId,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
|
|
ExpiresAt string `json:"expiresAt,omitempty"`
|
|
RefreshExpAt string `json:"refreshExpAt,omitempty"`
|
|
LastLoginAt string `json:"lastLoginAt,omitempty"`
|
|
LastUsedAt string `json:"lastUsedAt,omitempty"`
|
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
|
}
|
|
|
|
var (
|
|
runtimeProfileMu sync.RWMutex
|
|
runtimeProfile string
|
|
)
|
|
|
|
// SetRuntimeProfile sets a process-local one-shot profile override.
|
|
func SetRuntimeProfile(profile string) {
|
|
runtimeProfileMu.Lock()
|
|
defer runtimeProfileMu.Unlock()
|
|
runtimeProfile = strings.TrimSpace(profile)
|
|
}
|
|
|
|
// RuntimeProfile returns the process-local one-shot profile override.
|
|
func RuntimeProfile() string {
|
|
runtimeProfileMu.RLock()
|
|
defer runtimeProfileMu.RUnlock()
|
|
return runtimeProfile
|
|
}
|
|
|
|
// ProfilesPath returns the profile metadata path for a config dir.
|
|
func ProfilesPath(configDir string) string {
|
|
return filepath.Join(configDir, profilesJSONFile)
|
|
}
|
|
|
|
// LoadProfiles reads profiles.json. A missing file returns an empty config.
|
|
func LoadProfiles(configDir string) (*ProfilesConfig, error) {
|
|
path := ProfilesPath(configDir)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return &ProfilesConfig{Version: 1}, nil
|
|
}
|
|
return nil, fmt.Errorf("read profiles: %w", err)
|
|
}
|
|
var cfg ProfilesConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
// Corrupt file (e.g. an interrupted concurrent write): quarantine it and
|
|
// rebuild an empty config so the CLI can self-heal (auth reset / re-login)
|
|
// instead of being permanently locked out by an unreadable profiles.json.
|
|
quarantine := path + ".corrupt-" + time.Now().Format("20060102-150405.000")
|
|
_ = os.Rename(path, quarantine)
|
|
return &ProfilesConfig{Version: 1}, nil
|
|
}
|
|
normalizeProfilesConfig(&cfg)
|
|
return &cfg, nil
|
|
}
|
|
|
|
// SaveProfiles writes profiles.json atomically.
|
|
func SaveProfiles(configDir string, cfg *ProfilesConfig) error {
|
|
if cfg == nil {
|
|
cfg = &ProfilesConfig{}
|
|
}
|
|
normalizeProfilesConfig(cfg)
|
|
if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
|
|
return fmt.Errorf("create config dir: %w", err)
|
|
}
|
|
data, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal profiles: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
path := ProfilesPath(configDir)
|
|
// Per-write random temp name: a fixed "profiles.json.tmp" lets two
|
|
// concurrent writers interleave into the same temp file and rename a
|
|
// corrupted result into place.
|
|
tmp := path + "." + uuid.New().String() + ".tmp"
|
|
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
|
|
return fmt.Errorf("write profiles tmp: %w", err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("rename profiles: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// EnsureProfilesMigration initializes profiles.json from the legacy auth-token slot when needed.
|
|
// EnsureProfilesMigration migrates a legacy single-slot token into the
|
|
// profiles registry. It acquires the lock; call ensureProfilesMigrationLocked
|
|
// from contexts that already hold it (refresh / read paths).
|
|
func EnsureProfilesMigration(configDir string) error {
|
|
return withProfilesLock(configDir, func() error {
|
|
return ensureProfilesMigrationLocked(configDir)
|
|
})
|
|
}
|
|
|
|
func ensureProfilesMigrationLocked(configDir string) error {
|
|
cfg, err := LoadProfiles(configDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(cfg.Profiles) > 0 {
|
|
return nil
|
|
}
|
|
if !TokenDataExistsKeychain() {
|
|
return nil
|
|
}
|
|
data, err := LoadTokenDataKeychain()
|
|
if err != nil || data == nil || strings.TrimSpace(data.CorpID) == "" {
|
|
return nil
|
|
}
|
|
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
|
return err
|
|
}
|
|
return upsertProfileFromToken(configDir, cfg, data, false)
|
|
}
|
|
|
|
// UpsertProfileFromToken updates profiles.json after a successful login or refresh.
|
|
func UpsertProfileFromToken(configDir string, data *TokenData) error {
|
|
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
|
|
}
|
|
|
|
// UpsertProfileFromTokenWithCurrent updates profiles.json and optionally makes
|
|
// the token's corp the persistent current profile.
|
|
func UpsertProfileFromTokenWithCurrent(configDir string, data *TokenData, makeCurrent bool) error {
|
|
return withProfilesLock(configDir, func() error {
|
|
return upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent)
|
|
})
|
|
}
|
|
|
|
func upsertProfileFromTokenWithCurrentLocked(configDir string, data *TokenData, makeCurrent bool) error {
|
|
cfg, err := LoadProfiles(configDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return upsertProfileFromToken(configDir, cfg, data, makeCurrent)
|
|
}
|
|
|
|
func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenData, makeCurrent bool) error {
|
|
if data == nil {
|
|
return nil
|
|
}
|
|
corpID := strings.TrimSpace(data.CorpID)
|
|
if corpID == "" {
|
|
return nil
|
|
}
|
|
normalizeProfilesConfig(cfg)
|
|
now := time.Now().Format(time.RFC3339)
|
|
idx := profileIndexByCorpID(cfg, corpID)
|
|
if idx < 0 {
|
|
profile := Profile{
|
|
Name: chooseProfileName(cfg, data),
|
|
CorpID: corpID,
|
|
CorpName: strings.TrimSpace(data.CorpName),
|
|
UserID: strings.TrimSpace(data.UserID),
|
|
UserName: strings.TrimSpace(data.UserName),
|
|
ClientID: strings.TrimSpace(data.ClientID),
|
|
Status: ProfileStatusActive,
|
|
ExpiresAt: timeOrRFC3339(data.ExpiresAt),
|
|
RefreshExpAt: timeOrRFC3339(data.RefreshExpAt),
|
|
LastLoginAt: now,
|
|
LastUsedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
cfg.Profiles = append(cfg.Profiles, profile)
|
|
} else {
|
|
p := &cfg.Profiles[idx]
|
|
if shouldRefreshProfileName(p, data) {
|
|
p.Name = chooseProfileName(cfg, data)
|
|
}
|
|
if v := strings.TrimSpace(data.CorpName); v != "" {
|
|
p.CorpName = v
|
|
}
|
|
if v := strings.TrimSpace(data.UserID); v != "" {
|
|
p.UserID = v
|
|
}
|
|
if v := strings.TrimSpace(data.UserName); v != "" {
|
|
p.UserName = v
|
|
}
|
|
if v := strings.TrimSpace(data.ClientID); v != "" {
|
|
p.ClientID = v
|
|
}
|
|
p.Status = ProfileStatusActive
|
|
p.ExpiresAt = timeOrRFC3339(data.ExpiresAt)
|
|
p.RefreshExpAt = timeOrRFC3339(data.RefreshExpAt)
|
|
p.LastLoginAt = now
|
|
p.LastUsedAt = now
|
|
p.UpdatedAt = now
|
|
}
|
|
if cfg.PrimaryProfile == "" {
|
|
cfg.PrimaryProfile = corpID
|
|
}
|
|
if makeCurrent && cfg.CurrentProfile != corpID {
|
|
if cfg.CurrentProfile != "" {
|
|
cfg.PreviousProfile = cfg.CurrentProfile
|
|
}
|
|
cfg.CurrentProfile = corpID
|
|
}
|
|
if cfg.CurrentProfile == "" {
|
|
cfg.CurrentProfile = corpID
|
|
}
|
|
return SaveProfiles(configDir, cfg)
|
|
}
|
|
|
|
// ResolveProfile returns a profile selected by name/corpId or by current/primary fallback.
|
|
func ResolveProfile(configDir, selector string) (*Profile, error) {
|
|
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, err := LoadProfiles(configDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
selector = strings.TrimSpace(selector)
|
|
if selector != "" {
|
|
p := findProfile(cfg, selector)
|
|
if p == nil {
|
|
return nil, fmt.Errorf("profile %q not found", selector)
|
|
}
|
|
return p, nil
|
|
}
|
|
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
|
|
return p, nil
|
|
}
|
|
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
|
|
return p, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func resolveProfileForLoad(configDir, selector string) (*Profile, error) {
|
|
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, err := LoadProfiles(configDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
selector = strings.TrimSpace(selector)
|
|
if selector != "" {
|
|
p := findProfile(cfg, selector)
|
|
if p == nil {
|
|
return nil, fmt.Errorf("profile %q not found", selector)
|
|
}
|
|
return p, nil
|
|
}
|
|
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
|
|
if p := findProfile(cfg, candidate); p != nil && TokenDataExistsKeychainForCorpID(p.CorpID) {
|
|
return p, nil
|
|
}
|
|
}
|
|
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
|
|
return p, nil
|
|
}
|
|
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
|
|
return p, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// SetCurrentProfile persists the selected current profile.
|
|
func SetCurrentProfile(configDir, selector string) (*Profile, error) {
|
|
var result *Profile
|
|
err := withProfilesLock(configDir, func() error {
|
|
p, e := setCurrentProfileLocked(configDir, selector)
|
|
result = p
|
|
return e
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
|
|
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, err := LoadProfiles(configDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := findProfile(cfg, selector)
|
|
if p == nil {
|
|
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
|
|
}
|
|
if cfg.CurrentProfile != p.CorpID {
|
|
if cfg.CurrentProfile != "" {
|
|
cfg.PreviousProfile = cfg.CurrentProfile
|
|
}
|
|
cfg.CurrentProfile = p.CorpID
|
|
}
|
|
touchProfile(cfg, p.CorpID)
|
|
if err := SaveProfiles(configDir, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
|
return nil, err
|
|
}
|
|
return findProfile(cfg, p.CorpID), nil
|
|
}
|
|
|
|
// UsePreviousProfile toggles currentProfile and previousProfile.
|
|
func UsePreviousProfile(configDir string) (*Profile, error) {
|
|
var result *Profile
|
|
err := withProfilesLock(configDir, func() error {
|
|
p, e := usePreviousProfileLocked(configDir)
|
|
result = p
|
|
return e
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func usePreviousProfileLocked(configDir string) (*Profile, error) {
|
|
if err := ensureProfilesMigrationLocked(configDir); err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, err := LoadProfiles(configDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prev := strings.TrimSpace(cfg.PreviousProfile)
|
|
if prev == "" {
|
|
return nil, fmt.Errorf("previous profile is empty")
|
|
}
|
|
p := findProfile(cfg, prev)
|
|
if p == nil {
|
|
return nil, fmt.Errorf("previous profile %q not found", prev)
|
|
}
|
|
cfg.PreviousProfile, cfg.CurrentProfile = cfg.CurrentProfile, p.CorpID
|
|
touchProfile(cfg, p.CorpID)
|
|
if err := SaveProfiles(configDir, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
|
return nil, err
|
|
}
|
|
return findProfile(cfg, p.CorpID), nil
|
|
}
|
|
|
|
// RemoveProfile removes a profile from metadata and returns the removed profile.
|
|
func RemoveProfile(configDir, selector string) (*Profile, error) {
|
|
var result *Profile
|
|
err := withProfilesLock(configDir, func() error {
|
|
p, e := removeProfileLocked(configDir, selector)
|
|
result = p
|
|
return e
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func removeProfileLocked(configDir, selector string) (*Profile, error) {
|
|
cfg, err := LoadProfiles(configDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p := findProfile(cfg, selector)
|
|
if p == nil {
|
|
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
|
|
}
|
|
removed := *p
|
|
kept := cfg.Profiles[:0]
|
|
for _, profile := range cfg.Profiles {
|
|
if profile.CorpID != removed.CorpID {
|
|
kept = append(kept, profile)
|
|
}
|
|
}
|
|
cfg.Profiles = kept
|
|
if cfg.PrimaryProfile == removed.CorpID {
|
|
cfg.PrimaryProfile = firstProfileCorpID(cfg)
|
|
}
|
|
if cfg.CurrentProfile == removed.CorpID {
|
|
cfg.CurrentProfile = cfg.PrimaryProfile
|
|
if cfg.CurrentProfile == "" {
|
|
cfg.CurrentProfile = firstProfileCorpID(cfg)
|
|
}
|
|
}
|
|
if cfg.PreviousProfile == removed.CorpID {
|
|
cfg.PreviousProfile = ""
|
|
}
|
|
if len(cfg.Profiles) == 0 {
|
|
cfg.PrimaryProfile = ""
|
|
cfg.CurrentProfile = ""
|
|
cfg.PreviousProfile = ""
|
|
}
|
|
if err := SaveProfiles(configDir, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
return &removed, nil
|
|
}
|
|
|
|
// MarkProfileStatus updates a profile status if it exists.
|
|
func MarkProfileStatus(configDir, corpID, status string) error {
|
|
if strings.TrimSpace(corpID) == "" {
|
|
return nil
|
|
}
|
|
return withProfilesLock(configDir, func() error {
|
|
return markProfileStatusLocked(configDir, corpID, status)
|
|
})
|
|
}
|
|
|
|
func markProfileStatusLocked(configDir, corpID, status string) error {
|
|
cfg, err := LoadProfiles(configDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p := findProfile(cfg, corpID)
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
p.Status = strings.TrimSpace(status)
|
|
p.UpdatedAt = time.Now().Format(time.RFC3339)
|
|
return SaveProfiles(configDir, cfg)
|
|
}
|
|
|
|
// SyncLegacyTokenMirror mirrors the current profile token into legacy auth-token.
|
|
func SyncLegacyTokenMirror(configDir string) error {
|
|
return withProfilesLock(configDir, func() error {
|
|
return syncLegacyTokenMirrorLocked(configDir)
|
|
})
|
|
}
|
|
|
|
func syncLegacyTokenMirrorLocked(configDir string) error {
|
|
cfg, err := LoadProfiles(configDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hadReadError := false
|
|
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
|
|
p := findProfile(cfg, candidate)
|
|
if p == nil {
|
|
continue
|
|
}
|
|
data, loadErr := LoadTokenDataKeychainForCorpID(p.CorpID)
|
|
if loadErr != nil {
|
|
// Transient keychain read failure: do NOT touch the existing mirror.
|
|
hadReadError = true
|
|
continue
|
|
}
|
|
if data != nil {
|
|
if err := SaveTokenDataKeychain(data); err != nil {
|
|
return err
|
|
}
|
|
return WriteTokenMarker(configDir)
|
|
}
|
|
}
|
|
if hadReadError {
|
|
// Keep the existing legacy mirror untouched rather than wiping a host
|
|
// app's login state just because keychain was momentarily unavailable.
|
|
return nil
|
|
}
|
|
// All candidate profiles confirmed absent (no token): clear the mirror.
|
|
_ = DeleteTokenDataKeychain()
|
|
_ = DeleteTokenMarker(configDir)
|
|
return nil
|
|
}
|
|
|
|
func normalizeProfilesConfig(cfg *ProfilesConfig) {
|
|
if cfg == nil {
|
|
return
|
|
}
|
|
cfg.Version = 1
|
|
seen := make(map[string]bool, len(cfg.Profiles))
|
|
profiles := cfg.Profiles[:0]
|
|
for _, p := range cfg.Profiles {
|
|
p.CorpID = strings.TrimSpace(p.CorpID)
|
|
if p.CorpID == "" || seen[p.CorpID] {
|
|
continue
|
|
}
|
|
seen[p.CorpID] = true
|
|
p.Name = strings.TrimSpace(p.Name)
|
|
if p.Name == "" {
|
|
p.Name = p.CorpID
|
|
}
|
|
if corpName := strings.TrimSpace(p.CorpName); p.Name == p.CorpID && corpName != "" && !profileNameTakenByOtherCorp(cfg, corpName, p.CorpID) {
|
|
p.Name = corpName
|
|
}
|
|
if p.Status == "" {
|
|
p.Status = ProfileStatusActive
|
|
}
|
|
profiles = append(profiles, p)
|
|
}
|
|
cfg.Profiles = profiles
|
|
if cfg.PrimaryProfile != "" && findProfile(cfg, cfg.PrimaryProfile) == nil {
|
|
cfg.PrimaryProfile = ""
|
|
}
|
|
if cfg.CurrentProfile != "" && findProfile(cfg, cfg.CurrentProfile) == nil {
|
|
cfg.CurrentProfile = ""
|
|
}
|
|
if cfg.PreviousProfile != "" && findProfile(cfg, cfg.PreviousProfile) == nil {
|
|
cfg.PreviousProfile = ""
|
|
}
|
|
if cfg.PrimaryProfile == "" {
|
|
cfg.PrimaryProfile = firstProfileCorpID(cfg)
|
|
}
|
|
if cfg.CurrentProfile == "" {
|
|
cfg.CurrentProfile = cfg.PrimaryProfile
|
|
}
|
|
}
|
|
|
|
func chooseProfileName(cfg *ProfilesConfig, data *TokenData) string {
|
|
base := strings.TrimSpace(data.CorpName)
|
|
if base == "" {
|
|
base = strings.TrimSpace(data.CorpID)
|
|
}
|
|
if base == "" {
|
|
base = "profile"
|
|
}
|
|
if !profileNameTakenByOtherCorp(cfg, base, data.CorpID) {
|
|
return base
|
|
}
|
|
suffix := shortCorpID(data.CorpID)
|
|
name := base + "-" + suffix
|
|
if !profileNameTakenByOtherCorp(cfg, name, data.CorpID) {
|
|
return name
|
|
}
|
|
for i := 2; ; i++ {
|
|
candidate := fmt.Sprintf("%s-%s-%d", base, suffix, i)
|
|
if !profileNameTakenByOtherCorp(cfg, candidate, data.CorpID) {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
|
|
func shouldRefreshProfileName(p *Profile, data *TokenData) bool {
|
|
if p == nil || data == nil {
|
|
return false
|
|
}
|
|
name := strings.TrimSpace(p.Name)
|
|
if name == "" {
|
|
return true
|
|
}
|
|
return strings.TrimSpace(data.CorpName) != "" && name == strings.TrimSpace(p.CorpID)
|
|
}
|
|
|
|
func profileNameTakenByOtherCorp(cfg *ProfilesConfig, name, corpID string) bool {
|
|
name = strings.TrimSpace(name)
|
|
corpID = strings.TrimSpace(corpID)
|
|
for _, p := range cfg.Profiles {
|
|
if p.CorpID != corpID && p.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func findProfile(cfg *ProfilesConfig, selector string) *Profile {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
selector = strings.TrimSpace(selector)
|
|
if selector == "" {
|
|
return nil
|
|
}
|
|
var corpNameMatch *Profile
|
|
for i := range cfg.Profiles {
|
|
if cfg.Profiles[i].CorpID == selector || cfg.Profiles[i].Name == selector {
|
|
return &cfg.Profiles[i]
|
|
}
|
|
if strings.TrimSpace(cfg.Profiles[i].CorpName) == selector {
|
|
if corpNameMatch != nil {
|
|
return nil
|
|
}
|
|
corpNameMatch = &cfg.Profiles[i]
|
|
}
|
|
}
|
|
return corpNameMatch
|
|
}
|
|
|
|
func profileIndexByCorpID(cfg *ProfilesConfig, corpID string) int {
|
|
if cfg == nil {
|
|
return -1
|
|
}
|
|
for i := range cfg.Profiles {
|
|
if cfg.Profiles[i].CorpID == corpID {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func firstProfileCorpID(cfg *ProfilesConfig) string {
|
|
if cfg == nil || len(cfg.Profiles) == 0 {
|
|
return ""
|
|
}
|
|
return cfg.Profiles[0].CorpID
|
|
}
|
|
|
|
func touchProfile(cfg *ProfilesConfig, corpID string) {
|
|
if p := findProfile(cfg, corpID); p != nil {
|
|
now := time.Now().Format(time.RFC3339)
|
|
p.LastUsedAt = now
|
|
p.UpdatedAt = now
|
|
}
|
|
}
|
|
|
|
func timeOrRFC3339(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.Format(time.RFC3339)
|
|
}
|
|
|
|
func shortCorpID(corpID string) string {
|
|
corpID = strings.TrimSpace(corpID)
|
|
if len(corpID) <= 8 {
|
|
return corpID
|
|
}
|
|
return corpID[len(corpID)-8:]
|
|
}
|