Files
dingtalk-workspace-cli/internal/auth/profiles.go

2014 lines
64 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/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
var (
profilesAcquireDualLock = AcquireDualLock
profilesReadFile = os.ReadFile
profilesRename = os.Rename
profilesMkdirAll = os.MkdirAll
profilesMarshalIndent = json.MarshalIndent
profilesWriteFile = os.WriteFile
profilesRemove = os.Remove
profilesLoad = LoadProfiles
profilesSave = SaveProfiles
profilesEnsureMigration = ensureProfilesMigrationLocked
profilesSyncLegacyMirror = syncLegacyTokenMirrorLocked
profilesTokenExists = TokenDataExistsKeychain
profilesLoadLegacy = LoadTokenDataKeychain
profilesSaveCorp = SaveTokenDataKeychainForCorpID
profilesSaveIdentity = SaveTokenDataKeychainForIdentity
profilesTokenExistsCorp = TokenDataExistsKeychainForCorpID
profilesLoadCorp = LoadTokenDataKeychainForCorpID
profilesDeleteCorp = DeleteTokenDataKeychainForCorpID
profilesLoadIdentity = LoadTokenDataKeychainForIdentity
profilesSaveLegacy = SaveTokenDataKeychain
profilesWriteMarker = WriteTokenMarker
profilesWriteManualMarker = WriteManualTokenMarker
profilesDeleteLegacy = DeleteTokenDataKeychain
profilesDeleteMarker = DeleteTokenMarker
)
// 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 := profilesAcquireDualLock(context.Background(), configDir)
if err != nil {
return err
}
defer lock.Release()
return fn()
}
const (
profilesJSONFile = "profiles.json"
profilesVersion = 2
profilesUnresolvedSelectorVersion = 3
profilesMaxVersion = profilesUnresolvedSelectorVersion
unresolvedProfileSelectorPrefix = "@legacy/"
)
const (
ProfileStatusActive = "active"
ProfileStatusExpired = "expired"
ProfileStatusRevoked = "revoked"
ProfileStatusUnavailable = "unavailable"
)
// 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"`
OrgCurrentProfiles map[string]string `json:"orgCurrentProfiles,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"`
}
// SetRuntimeProfile sets a process-local one-shot profile override.
func SetRuntimeProfile(profile string) {
profilectx.Set(profile)
}
// RuntimeProfile returns the process-local one-shot profile override.
func RuntimeProfile() string {
return profilectx.Get()
}
// 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 := profilesReadFile(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")
_ = profilesRename(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{}
}
if err := ensureProfilesWritable(cfg); err != nil {
return err
}
normalizeProfilesConfig(cfg)
normalizeProfilesVersionForSelectors(cfg)
if err := profilesMkdirAll(configDir, config.DirPerm); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := profilesMarshalIndent(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 := profilesWriteFile(tmp, data, config.FilePerm); err != nil {
return fmt.Errorf("write profiles tmp: %w", err)
}
if err := profilesRename(tmp, path); err != nil {
_ = profilesRemove(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 := profilesLoad(configDir)
if err != nil {
return err
}
if cfg.Version > profilesMaxVersion {
return nil
}
if len(cfg.Profiles) == 0 {
// Version 2 with no profiles is an intentional logged-out tombstone.
// Never resurrect a stale legacy mirror after logout/reset.
if cfg.Version >= profilesVersion {
if normalizeProfilesVersionForSelectors(cfg) {
return profilesSave(configDir, cfg)
}
return nil
}
if !profilesTokenExists() {
return nil
}
data, loadErr := profilesLoadLegacy()
if loadErr != nil || data == nil || strings.TrimSpace(data.CorpID) == "" {
return nil
}
if err := profilesSaveCorp(data.CorpID, data); err != nil {
return err
}
if strings.TrimSpace(data.UserID) != "" {
if err := profilesSaveIdentity(data.CorpID, data.UserID, data); err != nil {
return err
}
}
return upsertProfileFromToken(configDir, cfg, data, false)
}
legacySelectionState := cfg.Version < profilesVersion
changed := false
if cfg.OrgCurrentProfiles == nil {
cfg.OrgCurrentProfiles = make(map[string]string)
}
orgTokens := make(map[string]*TokenData)
var legacyToken *TokenData
var legacyTokenErr error
legacyTokenLoaded := false
for i := range cfg.Profiles {
p := &cfg.Profiles[i]
corpID := strings.TrimSpace(p.CorpID)
if corpID == "" {
continue
}
orgToken, loaded := orgTokens[corpID]
if !loaded {
token, loadErr := profilesLoadCorp(corpID)
if legacySelectionState && loadErr != nil && !errors.Is(loadErr, ErrTokenDataNotFound) {
return loadErr
}
if loadErr != nil {
token = nil
}
var v2RepairProfile *Profile
if !legacySelectionState && errors.Is(loadErr, ErrTokenDataNotFound) {
v2RepairProfile = uniqueV2GlobalRepairProfile(cfg, corpID)
if v2RepairProfile != nil && strings.TrimSpace(v2RepairProfile.UserID) != "" {
_, identityErr := profilesLoadIdentity(corpID, v2RepairProfile.UserID)
switch {
case identityErr == nil:
// The exact identity is already usable. Do not let a stale
// global compatibility mirror recreate the organization slot.
v2RepairProfile = nil
case !errors.Is(identityErr, ErrTokenDataNotFound):
return identityErr
}
}
}
if (legacySelectionState || v2RepairProfile != nil) && errors.Is(loadErr, ErrTokenDataNotFound) {
// v1.0.50/1.0.51 installations can retain the selected
// organization only in the global compatibility slot. Consult
// that slot while migrating v1, or while repairing a non-empty
// v2 registry left half-migrated by an earlier CLI. The v2 path
// additionally requires one unambiguous profile, a missing exact
// slot when its userId is known, and a non-conflicting token userId.
// Persist the untouched organization mirror before identity
// enrichment below.
if !legacyTokenLoaded {
legacyToken, legacyTokenErr = profilesLoadLegacy()
legacyTokenLoaded = true
}
if legacyTokenErr != nil && !errors.Is(legacyTokenErr, ErrTokenDataNotFound) {
return legacyTokenErr
}
legacyMatchesProfile := legacySelectionState ||
legacyTokenMatchesV2RepairProfile(legacyToken, v2RepairProfile)
if legacyTokenErr == nil &&
legacyToken != nil &&
strings.TrimSpace(legacyToken.CorpID) == corpID &&
legacyMatchesProfile {
token = legacyToken
if err := profilesSaveCorp(corpID, token); err != nil {
return err
}
}
}
orgToken = token
orgTokens[corpID] = orgToken
}
if orgToken == nil {
continue
}
// v1.0.52 stored one token per organization. Some of those token blobs
// predate userId persistence even though profiles.json already recorded
// the account identity. Version 2 loads exact identities and therefore
// cannot safely use an organization mirror with no userId. A single
// profile with a known userId makes that association unambiguous,
// including when an earlier migration already bumped profiles.json to v2
// but failed before writing the identity slot. Enrich only the copy saved
// to that exact slot; never infer an identity for an organization with
// multiple accounts.
identityToken := orgToken
if strings.TrimSpace(orgToken.UserID) == "" &&
strings.TrimSpace(p.UserID) != "" &&
len(profilesForCorpID(cfg, corpID)) == 1 {
enriched := *orgToken
enriched.UserID = strings.TrimSpace(p.UserID)
if strings.TrimSpace(enriched.UserName) == "" {
enriched.UserName = strings.TrimSpace(p.UserName)
}
identityToken = &enriched
}
if strings.TrimSpace(p.UserID) == "" && strings.TrimSpace(orgToken.UserID) != "" {
if existing := findExactProfile(cfg, corpID, orgToken.UserID); existing != nil && existing != p {
p.CorpID = ""
changed = true
continue
}
p.UserID = strings.TrimSpace(orgToken.UserID)
if p.UserName == "" {
p.UserName = strings.TrimSpace(orgToken.UserName)
}
changed = true
}
if strings.TrimSpace(p.UserID) == "" || strings.TrimSpace(identityToken.UserID) != strings.TrimSpace(p.UserID) {
continue
}
_, identityErr := profilesLoadIdentity(corpID, p.UserID)
if errors.Is(identityErr, ErrTokenDataNotFound) {
if err := profilesSaveIdentity(corpID, p.UserID, identityToken); err != nil {
return err
}
} else if identityErr != nil {
return identityErr
}
}
for _, corpID := range uniqueProfileCorpIDs(cfg) {
if exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]) != "" {
continue
}
if _, exists := cfg.OrgCurrentProfiles[corpID]; exists {
delete(cfg.OrgCurrentProfiles, corpID)
changed = true
}
if !legacySelectionState {
continue
}
if orgToken := orgTokens[corpID]; orgToken != nil {
if p := findExactProfile(cfg, corpID, orgToken.UserID); p != nil {
cfg.OrgCurrentProfiles[corpID] = ProfileSelector(*p)
changed = true
continue
}
}
for _, pointer := range []string{cfg.CurrentProfile, cfg.PreviousProfile, cfg.PrimaryProfile} {
if exact := exactProfileSelectorForCorp(cfg, corpID, pointer); exact != "" {
cfg.OrgCurrentProfiles[corpID] = exact
changed = true
break
}
}
if cfg.OrgCurrentProfiles[corpID] != "" {
continue
}
profiles := profilesForCorpID(cfg, corpID)
if len(profiles) == 1 {
cfg.OrgCurrentProfiles[corpID] = ProfileSelector(*profiles[0])
changed = true
}
}
if exact := canonicalStoredSelector(cfg, cfg.CurrentProfile); exact != "" && exact != cfg.CurrentProfile {
cfg.CurrentProfile = exact
changed = true
}
if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" && exact != cfg.PrimaryProfile {
cfg.PrimaryProfile = exact
changed = true
}
if legacySelectionState && cfg.CurrentProfile == "" {
if exact := canonicalStoredSelector(cfg, cfg.PrimaryProfile); exact != "" {
cfg.CurrentProfile = exact
changed = true
} else if len(cfg.Profiles) == 1 {
cfg.CurrentProfile = ProfileSelector(cfg.Profiles[0])
changed = true
}
}
if exact := canonicalStoredSelector(cfg, cfg.PreviousProfile); exact != "" && exact != cfg.PreviousProfile {
cfg.PreviousProfile = exact
changed = true
}
if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile {
cfg.PreviousProfile = ""
changed = true
}
if cfg.Version < profilesVersion {
cfg.Version = profilesVersion
changed = true
}
if normalizeProfilesVersionForSelectors(cfg) {
changed = true
}
if changed {
return profilesSave(configDir, cfg)
}
return nil
}
// uniqueV2GlobalRepairProfile returns the only profile that may safely be
// recovered from the legacy global token mirror. A v2 registry with multiple
// accounts in one organization is deliberately ineligible, even if one token
// happens to carry a matching userId: the global slot is a mutable compatibility
// mirror and is not authoritative account-selection state. A sole unresolved
// profile is eligible only for organization-slot repair; the token matcher below
// rejects any global token that tries to attach a userId to it.
func uniqueV2GlobalRepairProfile(cfg *ProfilesConfig, corpID string) *Profile {
profiles := profilesForCorpID(cfg, corpID)
if len(profiles) != 1 {
return nil
}
return profiles[0]
}
func legacyTokenMatchesV2RepairProfile(data *TokenData, profile *Profile) bool {
if data == nil || strings.TrimSpace(data.CorpID) != strings.TrimSpace(profile.CorpID) {
return false
}
tokenUserID := strings.TrimSpace(data.UserID)
return tokenUserID == "" || tokenUserID == strings.TrimSpace(profile.UserID)
}
// 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 := profilesLoad(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
}
if err := ensureProfilesWritable(cfg); err != nil {
return err
}
normalizeProfilesConfig(cfg)
if cfg.Version < profilesVersion {
cfg.Version = profilesVersion
}
now := time.Now().Format(time.RFC3339)
userID := strings.TrimSpace(data.UserID)
var previousCurrent *Profile
if makeCurrent && strings.TrimSpace(cfg.CurrentProfile) != "" {
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
}
idx := profileIndexByIdentity(cfg, corpID, userID)
if idx < 0 && userID != "" && len(profilesForCorpID(cfg, corpID)) == 1 {
// Upgrade an organization-scoped v1 profile only when it is the sole
// account in that organization. If exact identities already coexist
// with a blank profile, consuming the blank profile here would silently
// discard that unresolved historical account.
idx = legacyProfileIndexByCorpID(cfg, corpID)
}
if idx < 0 {
profile := Profile{
Name: chooseProfileName(cfg, data),
CorpID: corpID,
CorpName: strings.TrimSpace(data.CorpName),
UserID: userID,
UserName: strings.TrimSpace(data.UserName),
ClientID: strings.TrimSpace(data.ClientID),
Status: ProfileStatusActive,
LastLoginAt: now,
LastUsedAt: now,
UpdatedAt: now,
}
cfg.Profiles = append(cfg.Profiles, profile)
idx = len(cfg.Profiles) - 1
} else {
p := &cfg.Profiles[idx]
if userID != "" {
p.UserID = userID
}
if shouldRefreshProfileName(p, data) {
p.Name = chooseProfileName(cfg, data)
}
if v := strings.TrimSpace(data.CorpName); v != "" {
p.CorpName = 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.LastLoginAt = now
p.LastUsedAt = now
p.UpdatedAt = now
}
storedProfile := &cfg.Profiles[idx]
if userID == "" && len(profilesForCorpID(cfg, corpID)) > 1 &&
(strings.TrimSpace(storedProfile.Name) == corpID || profileNameTakenByOtherIdentity(cfg, storedProfile.Name, corpID, "")) {
// A blank profile needs a stable name when exact identities coexist in
// the same organization; the corpId selector denotes the organization
// as a whole and is therefore not an exact account selector.
storedProfile.Name = chooseProfileName(cfg, data)
}
if makeCurrent {
newSelector := storedProfileSelector(cfg, storedProfile)
if previousCurrent != nil && storedProfileSelector(cfg, previousCurrent) != newSelector {
cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent)
}
cfg.CurrentProfile = newSelector
if userID == "" {
delete(cfg.OrgCurrentProfiles, corpID)
} else {
setOrgCurrentProfile(cfg, corpID, newSelector)
}
}
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = storedProfileSelector(cfg, storedProfile)
if userID == "" {
delete(cfg.OrgCurrentProfiles, corpID)
} else {
setOrgCurrentProfile(cfg, corpID, cfg.CurrentProfile)
}
}
return profilesSave(configDir, cfg)
}
// ProfileSelector returns the exact identity selector for a profile when its
// userId is known, otherwise it returns the historical corpId selector.
func ProfileSelector(profile Profile) string {
return profileSelector(profile.CorpID, profile.UserID)
}
// storedProfileSelector returns a selector that remains exact inside
// profiles.json. A blank userId has only an organization selector in the
// public compatibility surface; when other accounts share that organization,
// use the profile's unique local name so current/previous pointers do not
// accidentally resolve to an exact account through OrgCurrentProfiles.
func storedProfileSelector(cfg *ProfilesConfig, profile *Profile) string {
if profile == nil {
return ""
}
if strings.TrimSpace(profile.UserID) != "" {
return ProfileSelector(*profile)
}
if cfg == nil {
return strings.TrimSpace(profile.CorpID)
}
if len(profilesForCorpID(cfg, profile.CorpID)) <= 1 {
return strings.TrimSpace(profile.CorpID)
}
name := strings.TrimSpace(profile.Name)
if localProfileSelectorIsSafe(cfg, profile, name) {
return name
}
return unresolvedProfileSelector(profile.CorpID)
}
// ProfileSelectionSelector returns the stable selector used for one profile.
// Exact identities use corpId:userId. A historical profile without userId
// keeps the organization selector while it is the only account, and otherwise
// uses either an unambiguous local name or a reserved, reversible selector.
func ProfileSelectionSelector(profile Profile, cfg *ProfilesConfig) string {
return storedProfileSelector(cfg, &profile)
}
func localProfileSelectorIsSafe(cfg *ProfilesConfig, profile *Profile, name string) bool {
if cfg == nil || profile == nil {
return false
}
name = strings.TrimSpace(name)
if name == "" || strings.Contains(name, ":") || strings.HasPrefix(name, unresolvedProfileSelectorPrefix) {
return false
}
nameMatches := 0
for i := range cfg.Profiles {
candidate := &cfg.Profiles[i]
if strings.TrimSpace(candidate.Name) == name {
nameMatches++
}
// Organization selectors are resolved before ordinary local names.
// Never persist a local selector that can be captured by that grammar.
if strings.TrimSpace(candidate.CorpID) == name || strings.TrimSpace(candidate.CorpName) == name {
return false
}
}
return nameMatches == 1
}
func unresolvedProfileSelector(corpID string) string {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return ""
}
return unresolvedProfileSelectorPrefix + base64.RawURLEncoding.EncodeToString([]byte(corpID))
}
func parseUnresolvedProfileSelector(selector string) (string, bool) {
selector = strings.TrimSpace(selector)
if !strings.HasPrefix(selector, unresolvedProfileSelectorPrefix) {
return "", false
}
encoded := strings.TrimPrefix(selector, unresolvedProfileSelectorPrefix)
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", false
}
corpID := strings.TrimSpace(string(decoded))
if corpID == "" || unresolvedProfileSelector(corpID) != selector {
return "", false
}
return corpID, true
}
// TokenProfileSelector returns the exact identity selector for token data when
// its userId is known, otherwise it returns the historical corpId selector.
func TokenProfileSelector(data *TokenData) string {
if data == nil {
return ""
}
return profileSelector(data.CorpID, data.UserID)
}
// StableTokenProfileSelector preserves the exact selector that loaded a token.
// This matters for an unresolved historical account sharing an organization
// with exact identities: reducing its selector to corpId would follow
// OrgCurrentProfiles and could mark or reauthorize a different account.
func StableTokenProfileSelector(configDir string, data *TokenData) string {
if selector := strings.TrimSpace(RuntimeProfile()); selector != "" {
return selector
}
fallback := TokenProfileSelector(data)
if data == nil {
return fallback
}
cfg, err := LoadProfiles(configDir)
if err != nil || cfg == nil || strings.TrimSpace(cfg.CurrentProfile) == "" {
return fallback
}
selector := canonicalStoredSelector(cfg, cfg.CurrentProfile)
if selector == "" {
selector = strings.TrimSpace(cfg.CurrentProfile)
}
profile, _, err := resolveProfileSelection(configDir, cfg, selector)
if err != nil || profile == nil ||
!sameProfileIdentity(profile.CorpID, profile.UserID, data.CorpID, data.UserID) {
return fallback
}
return storedProfileSelector(cfg, profile)
}
func profileSelector(corpID, userID string) string {
corpID = strings.TrimSpace(corpID)
userID = strings.TrimSpace(userID)
if corpID == "" {
return ""
}
if userID == "" {
return corpID
}
return corpID + ":" + userID
}
// ParseIdentitySelector splits corpId:userId selectors.
func ParseIdentitySelector(selector string) (corpID, userID string, ok bool) {
selector = strings.TrimSpace(selector)
idx := strings.Index(selector, ":")
if idx <= 0 || idx >= len(selector)-1 {
return "", "", false
}
corpID = strings.TrimSpace(selector[:idx])
userID = strings.TrimSpace(selector[idx+1:])
if corpID == "" || userID == "" {
return "", "", false
}
return corpID, userID, true
}
// ResolveProfile returns a profile selected by name/corpId/identity or by
// current fallback.
func ResolveProfile(configDir, selector string) (*Profile, error) {
p, _, err := ResolveProfileWithScope(configDir, selector)
return p, err
}
// ResolveProfileWithScope resolves a selector and reports whether it targets
// one identity (compound selector or local profile name) rather than an
// organization as a whole.
func ResolveProfileWithScope(configDir, selector string) (*Profile, bool, error) {
var (
result *Profile
exact bool
)
err := withProfilesLock(configDir, func() error {
var resolveErr error
result, exact, resolveErr = resolveProfileWithScopeLocked(configDir, selector)
return resolveErr
})
return result, exact, err
}
// ResolveProfileMetadata applies the public profile-selector grammar to an
// already-loaded, non-sensitive profiles registry. It performs no migration,
// keychain access, token loading, or persistence, making it suitable for
// callers that carry an externally managed bearer credential and need only
// corp/user/client identity metadata.
func ResolveProfileMetadata(cfg *ProfilesConfig, selector string) (*Profile, error) {
profile, _, err := resolveProfileSelection("", cfg, selector)
if err != nil || profile == nil {
return nil, err
}
copy := *profile
return &copy, nil
}
func resolveProfileWithScopeLocked(configDir, selector string) (*Profile, bool, error) {
if err := profilesEnsureMigration(configDir); err != nil {
return nil, false, err
}
cfg, err := profilesLoad(configDir)
if err != nil {
return nil, false, err
}
selector = strings.TrimSpace(selector)
if selector != "" {
return resolveProfileSelection(configDir, cfg, selector)
}
if strings.TrimSpace(cfg.CurrentProfile) != "" {
return resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
}
return nil, false, nil
}
// ResolveProfileDeletionScope resolves selectors for destructive profile
// removal. Organization selectors intentionally resolve to the whole
// organization even when it has multiple accounts and no current account.
func ResolveProfileDeletionScope(configDir, selector string) (*Profile, bool, error) {
var (
result *Profile
exact bool
)
err := withProfilesLock(configDir, func() error {
if err := profilesEnsureMigration(configDir); err != nil {
return err
}
cfg, err := profilesLoad(configDir)
if err != nil {
return err
}
if err := ensureProfilesWritable(cfg); err != nil {
return err
}
var resolveErr error
result, exact, resolveErr = resolveProfileDeletionSelection(cfg, selector)
return resolveErr
})
return result, exact, err
}
func resolveProfileForLoadLocked(configDir, selector string) (*Profile, *ProfilesConfig, error) {
if err := profilesEnsureMigration(configDir); err != nil {
return nil, nil, err
}
cfg, err := profilesLoad(configDir)
if err != nil {
return nil, nil, err
}
selector = strings.TrimSpace(selector)
if selector != "" {
p, _, resolveErr := resolveProfileSelection(configDir, cfg, selector)
return p, cfg, resolveErr
}
if strings.TrimSpace(cfg.CurrentProfile) != "" {
p, _, resolveErr := resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
return p, cfg, resolveErr
}
return nil, cfg, nil
}
// resolveProfileForLoad is retained as a focused test seam around the locked
// resolver. Production callers already hold the auth lock.
func resolveProfileForLoad(configDir, selector string) (*Profile, error) {
profile, _, err := resolveProfileForLoadLocked(configDir, selector)
return profile, err
}
// 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 := profilesEnsureMigration(configDir); err != nil {
return nil, err
}
cfg, err := profilesLoad(configDir)
if err != nil {
return nil, err
}
if err := ensureProfilesWritable(cfg); err != nil {
return nil, err
}
p, _, err := resolveProfileSelection(configDir, cfg, selector)
if err != nil {
return nil, err
}
originalCfg := cloneProfilesConfig(cfg)
syncOrganization := shouldSyncOrganizationMirror(cfg, *p)
if !syncOrganization {
if err := validateIdentityOnlyProfileToken(*p); err != nil {
return nil, err
}
}
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization)
if err != nil {
return nil, err
}
var previousCurrent *Profile
if strings.TrimSpace(cfg.CurrentProfile) != "" {
previousCurrent, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
}
storedSelector := storedProfileSelector(cfg, p)
if cfg.CurrentProfile != storedSelector {
if previousCurrent != nil {
cfg.PreviousProfile = storedProfileSelector(cfg, previousCurrent)
}
cfg.CurrentProfile = storedSelector
}
if strings.TrimSpace(p.UserID) == "" {
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(p.CorpID))
} else {
setOrgCurrentProfile(cfg, p.CorpID, storedSelector)
}
touchProfileUsage(p)
if err := profilesSave(configDir, cfg); err != nil {
return nil, err
}
if syncOrganization {
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
}
}
if err := profilesSyncLegacyMirror(configDir); err != nil {
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
}
return findExactProfile(cfg, p.CorpID, p.UserID), 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 := profilesEnsureMigration(configDir); err != nil {
return nil, err
}
cfg, err := profilesLoad(configDir)
if err != nil {
return nil, err
}
if err := ensureProfilesWritable(cfg); err != nil {
return nil, err
}
prev := strings.TrimSpace(cfg.PreviousProfile)
if prev == "" {
return nil, fmt.Errorf("previous profile is empty")
}
p, _, err := resolveProfileSelection(configDir, cfg, prev)
if err != nil {
return nil, fmt.Errorf("resolve previous profile %q: %w", prev, err)
}
originalCfg := cloneProfilesConfig(cfg)
syncOrganization := shouldSyncOrganizationMirror(cfg, *p)
if !syncOrganization {
if err := validateIdentityOnlyProfileToken(*p); err != nil {
return nil, err
}
}
mirrors, err := snapshotProfileSelectionMirrors(configDir, p.CorpID, syncOrganization)
if err != nil {
return nil, err
}
var current *Profile
if strings.TrimSpace(cfg.CurrentProfile) != "" {
current, _, _ = resolveProfileSelection(configDir, cfg, cfg.CurrentProfile)
}
cfg.CurrentProfile = storedProfileSelector(cfg, p)
if current != nil {
cfg.PreviousProfile = storedProfileSelector(cfg, current)
} else {
cfg.PreviousProfile = ""
}
if strings.TrimSpace(p.UserID) == "" {
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(p.CorpID))
} else {
setOrgCurrentProfile(cfg, p.CorpID, ProfileSelector(*p))
}
touchProfileUsage(p)
if err := profilesSave(configDir, cfg); err != nil {
return nil, err
}
if syncOrganization {
if err := syncOrganizationTokenMirrorForProfile(*p); err != nil {
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
}
}
if err := profilesSyncLegacyMirror(configDir); err != nil {
return nil, rollbackProfileSelection(configDir, originalCfg, p.CorpID, mirrors, err)
}
return findExactProfile(cfg, p.CorpID, p.UserID), nil
}
func touchProfileUsage(profile *Profile) {
if profile == nil {
return
}
now := time.Now().Format(time.RFC3339)
profile.LastUsedAt = now
profile.UpdatedAt = now
}
// 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 {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return err
}
p, e := removeProfileLocked(configDir, selector)
result = p
return e
})
return result, err
}
func removeProfileLocked(configDir, selector string) (*Profile, error) {
cfg, err := profilesLoad(configDir)
if err != nil {
return nil, err
}
if err := ensureProfilesWritable(cfg); err != nil {
return nil, err
}
p, exact, err := resolveProfileDeletionSelection(cfg, selector)
if err != nil {
return nil, err
}
removed := *p
originalCurrentSelector := strings.TrimSpace(cfg.CurrentProfile)
originalOrganizationCurrent := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(removed.CorpID)])
pointers := []*string{&cfg.PrimaryProfile, &cfg.CurrentProfile, &cfg.PreviousProfile}
pointerMatches := make([]bool, len(pointers))
for i, pointer := range pointers {
selected, _, resolveErr := resolveProfileSelection(configDir, cfg, *pointer)
if resolveErr == nil && selected != nil {
if exact {
pointerMatches[i] =
sameProfileIdentity(selected.CorpID, selected.UserID, removed.CorpID, removed.UserID)
} else {
pointerMatches[i] = strings.TrimSpace(selected.CorpID) == strings.TrimSpace(removed.CorpID)
}
}
}
kept := cfg.Profiles[:0]
for _, profile := range cfg.Profiles {
remove := profile.CorpID == removed.CorpID
if exact {
remove = sameProfileIdentity(profile.CorpID, profile.UserID, removed.CorpID, removed.UserID)
}
if !remove {
kept = append(kept, profile)
}
}
cfg.Profiles = kept
replacementSelector := ""
if exact && len(profilesForCorpID(cfg, removed.CorpID)) > 0 {
remaining := profilesForCorpID(cfg, removed.CorpID)
if len(remaining) == 1 {
replacementSelector = storedProfileSelector(cfg, remaining[0])
}
}
if exact {
if selectorMatchesIdentity(cfg.OrgCurrentProfiles[removed.CorpID], removed) {
if replacementSelector == "" {
delete(cfg.OrgCurrentProfiles, removed.CorpID)
} else {
cfg.OrgCurrentProfiles[removed.CorpID] = replacementSelector
}
}
} else {
delete(cfg.OrgCurrentProfiles, removed.CorpID)
}
for i, pointer := range pointers {
if exact {
if pointerMatches[i] {
*pointer = replacementSelector
}
continue
}
if pointerMatches[i] || selectorTargetsCorp(*pointer, removed.CorpID) {
*pointer = ""
}
}
if cfg.CurrentProfile == "" {
if previous := canonicalStoredSelector(cfg, cfg.PreviousProfile); previous != "" {
cfg.CurrentProfile = previous
cfg.PreviousProfile = ""
} else if len(cfg.Profiles) == 1 {
cfg.CurrentProfile = storedProfileSelector(cfg, &cfg.Profiles[0])
}
}
if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile {
cfg.PreviousProfile = ""
}
currentSelectionChanged := strings.TrimSpace(cfg.CurrentProfile) != originalCurrentSelector
organizationCurrentChanged := strings.TrimSpace(cfg.OrgCurrentProfiles[strings.TrimSpace(removed.CorpID)]) != originalOrganizationCurrent
if strings.TrimSpace(cfg.CurrentProfile) != "" {
if current, _, resolveErr := resolveProfileSelection(configDir, cfg, cfg.CurrentProfile); resolveErr == nil && current != nil {
if (currentSelectionChanged || organizationCurrentChanged) &&
strings.TrimSpace(current.CorpID) == strings.TrimSpace(removed.CorpID) &&
unresolvedProfileForCorp(cfg, removed.CorpID) != nil {
if strings.TrimSpace(current.UserID) == "" {
delete(cfg.OrgCurrentProfiles, strings.TrimSpace(current.CorpID))
} else {
setOrgCurrentProfile(cfg, current.CorpID, storedProfileSelector(cfg, current))
}
}
}
}
// Removing the exact identity that forced a blank sibling to use the v3
// reserved selector can make that blank profile the sole account in its
// organization. Re-canonicalize every surviving pointer against the final
// profile set before SaveProfiles derives the schema version, so the pointer
// collapses back to the v2 corpId grammar instead of pinning the file at v3.
for _, pointer := range pointers {
if canonical := canonicalStoredSelector(cfg, *pointer); canonical != "" {
*pointer = canonical
}
}
if cfg.PreviousProfile != "" && cfg.PreviousProfile == cfg.CurrentProfile {
cfg.PreviousProfile = ""
}
if len(cfg.Profiles) == 0 {
cfg.PrimaryProfile = ""
cfg.CurrentProfile = ""
cfg.PreviousProfile = ""
cfg.OrgCurrentProfiles = nil
}
if err := profilesSave(configDir, cfg); err != nil {
return nil, err
}
return &removed, nil
}
func selectorMatchesIdentity(selector string, profile Profile) bool {
corpID, userID, exact := ParseIdentitySelector(selector)
return exact && sameProfileIdentity(corpID, userID, profile.CorpID, profile.UserID)
}
func selectorTargetsCorp(selector, corpID string) bool {
selector = strings.TrimSpace(selector)
corpID = strings.TrimSpace(corpID)
if selector == corpID {
return true
}
if selectedCorpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
return selectedCorpID == corpID
}
selectedCorpID, _, exact := ParseIdentitySelector(selector)
return exact && selectedCorpID == corpID
}
// MarkProfileStatus updates a selected profile status if it exists.
func MarkProfileStatus(configDir, selector, status string) error {
if strings.TrimSpace(selector) == "" {
return nil
}
return withProfilesLock(configDir, func() error {
return markProfileStatusLocked(configDir, selector, status)
})
}
func markProfileStatusLocked(configDir, selector, status string) error {
cfg, err := profilesLoad(configDir)
if err != nil {
return err
}
if err := ensureProfilesWritable(cfg); err != nil {
return err
}
p, _, resolveErr := resolveProfileSelection(configDir, cfg, selector)
if resolveErr != nil {
return nil
}
p.Status = strings.TrimSpace(status)
p.UpdatedAt = time.Now().Format(time.RFC3339)
return profilesSave(configDir, cfg)
}
func ensureProfilesWritable(cfg *ProfilesConfig) error {
if cfg != nil && cfg.Version > profilesMaxVersion {
return fmt.Errorf(
"profiles.json version %d is newer than supported version %d; upgrade dws before changing profiles",
cfg.Version,
profilesMaxVersion,
)
}
return nil
}
// normalizeProfilesVersionForSelectors derives the persisted schema version
// from the final normalized selector grammar. Keep v3 only while a legal
// reserved unresolved-identity selector remains on disk; once completion,
// deletion, or canonicalization removes that grammar, the file is again safe
// for v2 clients and should downgrade to v2.
func normalizeProfilesVersionForSelectors(cfg *ProfilesConfig) bool {
if cfg == nil || cfg.Version > profilesMaxVersion {
return false
}
target := cfg.Version
if profilesConfigContainsUnresolvedSelector(cfg) {
target = profilesUnresolvedSelectorVersion
} else if cfg.Version >= profilesVersion {
target = profilesVersion
}
if target == cfg.Version {
return false
}
cfg.Version = target
return true
}
func profilesConfigContainsUnresolvedSelector(cfg *ProfilesConfig) bool {
if cfg == nil {
return false
}
for _, selector := range []string{cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile} {
if _, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
return true
}
}
for _, selector := range cfg.OrgCurrentProfiles {
if _, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
return true
}
}
return false
}
type profileSelectionMirrorSnapshot struct {
organization tokenSlotSnapshot
legacy tokenSlotSnapshot
marker tokenMarkerSnapshot
}
func snapshotProfileSelectionMirrors(configDir, corpID string, includeOrganization bool) (profileSelectionMirrorSnapshot, error) {
var organization tokenSlotSnapshot
if includeOrganization {
var err error
organization, err = snapshotTokenSlot(func() (*TokenData, error) {
return profilesLoadCorp(corpID)
})
if err != nil {
return profileSelectionMirrorSnapshot{}, err
}
}
legacy, err := snapshotTokenSlot(profilesLoadLegacy)
if err != nil {
return profileSelectionMirrorSnapshot{}, err
}
marker, err := snapshotTokenMarker(configDir)
if err != nil {
return profileSelectionMirrorSnapshot{}, err
}
return profileSelectionMirrorSnapshot{
organization: organization,
legacy: legacy,
marker: marker,
}, nil
}
func rollbackProfileSelection(
configDir string,
cfg *ProfilesConfig,
corpID string,
mirrors profileSelectionMirrorSnapshot,
operationErr error,
) error {
var rollbackErr error
if err := profilesSave(configDir, cloneProfilesConfig(cfg)); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
if mirrors.organization.known {
if mirrors.organization.exists {
if err := profilesSaveCorp(corpID, mirrors.organization.token); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
} else if err := profilesDeleteCorp(corpID); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
if mirrors.legacy.exists {
if err := profilesSaveLegacy(mirrors.legacy.token); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
} else if err := profilesDeleteLegacy(); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
switch {
case !mirrors.marker.exists:
if err := profilesDeleteMarker(configDir); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
case mirrors.marker.manual:
if err := profilesWriteManualMarker(configDir); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
default:
if err := profilesWriteMarker(configDir); err != nil {
rollbackErr = errors.Join(rollbackErr, err)
}
}
if rollbackErr != nil {
return errors.Join(operationErr, fmt.Errorf("rollback profile selection: %w", rollbackErr))
}
return operationErr
}
// 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 := profilesLoad(configDir)
if err != nil {
return err
}
current := strings.TrimSpace(cfg.CurrentProfile)
if current != "" {
p, _, resolveErr := resolveProfileSelection(configDir, cfg, current)
if resolveErr != nil {
return resolveErr
}
data, loadErr := loadTokenForProfileIdentity(*p)
if loadErr != nil {
if errors.Is(loadErr, ErrTokenDataNotFound) {
// The current identity and its organization mirror are both
// confirmed absent. Clear the stale global compatibility mirror.
} else {
// Keep the existing legacy mirror untouched rather than wiping a host
// app's login state just because keychain was momentarily unavailable.
return nil
}
}
if data != nil {
if err := profilesSaveLegacy(data); err != nil {
return err
}
return profilesWriteMarker(configDir)
}
}
// No current profile (or its token is confirmed absent): clear the mirror.
if err := profilesDeleteLegacy(); err != nil {
return err
}
return profilesDeleteMarker(configDir)
}
func syncOrganizationTokenMirrorForProfile(profile Profile) error {
data, err := loadTokenForProfileIdentity(profile)
if err != nil {
return err
}
return profilesSaveCorp(profile.CorpID, data)
}
func loadTokenForProfileIdentity(profile Profile) (*TokenData, error) {
if strings.TrimSpace(profile.UserID) == "" {
data, err := profilesLoadCorp(profile.CorpID)
if err != nil {
return nil, err
}
if data == nil {
return nil, ErrTokenDataNotFound
}
if strings.TrimSpace(data.UserID) != "" {
return nil, fmt.Errorf(
"organization token mirror for corpId %q belongs to userId %q; cannot use it for unresolved profile %q",
profile.CorpID,
data.UserID,
profile.Name,
)
}
return data, nil
}
data, err := profilesLoadIdentity(profile.CorpID, profile.UserID)
if err == nil {
return data, nil
}
if !errors.Is(err, ErrTokenDataNotFound) {
return nil, err
}
orgData, orgErr := profilesLoadCorp(profile.CorpID)
if orgErr != nil {
if errors.Is(orgErr, ErrTokenDataNotFound) {
return nil, err
}
return nil, orgErr
}
if strings.TrimSpace(orgData.UserID) == "" {
return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", profile.CorpID, ProfileSelector(profile))
}
if strings.TrimSpace(orgData.UserID) != strings.TrimSpace(profile.UserID) {
return nil, err
}
if saveErr := profilesSaveIdentity(profile.CorpID, profile.UserID, orgData); saveErr != nil {
return nil, saveErr
}
return orgData, nil
}
func normalizeProfilesConfig(cfg *ProfilesConfig) {
if cfg == nil {
return
}
if cfg.Version <= 0 {
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)
p.UserID = strings.TrimSpace(p.UserID)
identity := profileIdentityKey(p.CorpID, p.UserID)
if p.CorpID == "" || seen[identity] {
continue
}
seen[identity] = 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 != "" && !profileNameTakenByOtherIdentity(cfg, corpName, p.CorpID, p.UserID) {
p.Name = corpName
}
if p.Status == "" {
p.Status = ProfileStatusActive
}
profiles = append(profiles, p)
}
cfg.Profiles = profiles
if len(cfg.OrgCurrentProfiles) > 0 {
normalized := make(map[string]string, len(cfg.OrgCurrentProfiles))
for corpID, selector := range cfg.OrgCurrentProfiles {
corpID = strings.TrimSpace(corpID)
if exact := exactProfileSelectorForCorp(cfg, corpID, selector); exact != "" {
normalized[corpID] = exact
}
}
if len(normalized) == 0 {
cfg.OrgCurrentProfiles = nil
} else {
cfg.OrgCurrentProfiles = normalized
}
}
if cfg.PrimaryProfile != "" && !profileSelectorReferenceExists(cfg, cfg.PrimaryProfile) {
cfg.PrimaryProfile = ""
}
if cfg.CurrentProfile != "" && !profileSelectorReferenceExists(cfg, cfg.CurrentProfile) {
cfg.CurrentProfile = ""
}
if cfg.PreviousProfile != "" && !profileSelectorReferenceExists(cfg, cfg.PreviousProfile) {
cfg.PreviousProfile = ""
}
}
func chooseProfileName(cfg *ProfilesConfig, data *TokenData) string {
base := strings.TrimSpace(data.CorpName)
if base == "" {
base = strings.TrimSpace(data.CorpID)
}
if base == "" {
base = "profile"
}
corpID := strings.TrimSpace(data.CorpID)
userID := strings.TrimSpace(data.UserID)
if !profileNameTakenByOtherIdentity(cfg, base, corpID, userID) {
return base
}
for _, suffix := range []string{
strings.TrimSpace(data.UserName),
shortProfileID(userID),
shortCorpID(corpID),
} {
if suffix == "" {
continue
}
name := base + "-" + suffix
if !profileNameTakenByOtherIdentity(cfg, name, corpID, userID) {
return name
}
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s-%d", base, i)
if !profileNameTakenByOtherIdentity(cfg, candidate, corpID, userID) {
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 profileNameTakenByOtherIdentity(cfg *ProfilesConfig, name, corpID, userID string) bool {
name = strings.TrimSpace(name)
corpID = strings.TrimSpace(corpID)
userID = strings.TrimSpace(userID)
for _, p := range cfg.Profiles {
if strings.TrimSpace(p.Name) == name && !sameProfileIdentity(p.CorpID, p.UserID, corpID, userID) {
return true
}
}
return false
}
// resolveProfileSelection resolves a user-facing selector to one exact identity.
// The bool reports whether the selector targets one identity (compound selector
// or local profile name) rather than an organization as a whole.
func resolveProfileSelection(_ string, cfg *ProfilesConfig, selector string) (*Profile, bool, error) {
if cfg == nil {
return nil, false, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
}
selector = strings.TrimSpace(selector)
if selector == "" {
return nil, false, fmt.Errorf("profile selector is empty")
}
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
return profile, true, nil
}
return nil, true, fmt.Errorf("historical profile for organization %q not found", corpID)
}
if organization, account, compound := ParseIdentitySelector(selector); compound {
corpID, err := resolveOrganizationCorpID(cfg, organization)
if err != nil {
return nil, true, err
}
if corpID == "" {
return nil, true, fmt.Errorf("organization %q not found", organization)
}
if p := findExactProfile(cfg, corpID, account); p != nil {
return p, true, nil
}
var matches []*Profile
for _, p := range profilesForCorpID(cfg, corpID) {
if strings.TrimSpace(p.UserName) == account {
matches = append(matches, p)
}
}
switch len(matches) {
case 1:
return matches[0], true, nil
case 0:
return nil, true, fmt.Errorf("account %q not found in organization %q", account, organization)
default:
return nil, true, fmt.Errorf(
"account name %q is ambiguous in organization %q; use one of: %s",
account,
organization,
strings.Join(profileSelectorCandidates(matches), ", "),
)
}
}
if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 {
return resolveOrganizationDefault(cfg, selector, selector, profiles)
}
orgIDs := make(map[string]struct{})
for i := range cfg.Profiles {
if strings.TrimSpace(cfg.Profiles[i].CorpName) != selector {
continue
}
orgIDs[strings.TrimSpace(cfg.Profiles[i].CorpID)] = struct{}{}
}
if len(orgIDs) == 1 {
for corpID := range orgIDs {
return resolveOrganizationDefault(cfg, corpID, selector, profilesForCorpID(cfg, corpID))
}
}
if len(orgIDs) > 1 {
candidates := make([]string, 0, len(orgIDs))
for corpID := range orgIDs {
candidates = append(candidates, corpID)
}
sort.Strings(candidates)
return nil, false, fmt.Errorf(
"organization name %q is ambiguous; use one of: %s",
selector,
strings.Join(candidates, ", "),
)
}
var nameMatches []*Profile
for i := range cfg.Profiles {
if strings.TrimSpace(cfg.Profiles[i].Name) != selector {
continue
}
nameMatches = append(nameMatches, &cfg.Profiles[i])
}
switch len(nameMatches) {
case 1:
return nameMatches[0], true, nil
case 0:
return nil, false, fmt.Errorf("profile %q not found", selector)
default:
return nil, true, fmt.Errorf(
"profile name %q is ambiguous; use one of: %s",
selector,
strings.Join(profileSelectorCandidates(nameMatches), ", "),
)
}
}
func resolveProfileDeletionSelection(cfg *ProfilesConfig, selector string) (*Profile, bool, error) {
if cfg == nil {
return nil, false, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
}
selector = strings.TrimSpace(selector)
if selector == "" {
return nil, false, fmt.Errorf("profile selector is empty")
}
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
return profile, true, nil
}
return nil, true, fmt.Errorf("historical profile for organization %q not found", corpID)
}
if _, _, compound := ParseIdentitySelector(selector); compound {
return resolveProfileSelection("", cfg, selector)
}
if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 {
return profiles[0], false, nil
}
corpID, err := resolveOrganizationCorpID(cfg, selector)
if err != nil {
return nil, false, err
}
if corpID != "" {
profiles := profilesForCorpID(cfg, corpID)
if len(profiles) > 0 {
return profiles[0], false, nil
}
}
var nameMatches []*Profile
for i := range cfg.Profiles {
if strings.TrimSpace(cfg.Profiles[i].Name) == selector {
nameMatches = append(nameMatches, &cfg.Profiles[i])
}
}
switch len(nameMatches) {
case 1:
return nameMatches[0], true, nil
case 0:
return nil, false, fmt.Errorf("profile %q not found", selector)
default:
return nil, true, fmt.Errorf(
"profile name %q is ambiguous; use one of: %s",
selector,
strings.Join(profileSelectorCandidates(nameMatches), ", "),
)
}
}
func resolveOrganizationCorpID(cfg *ProfilesConfig, selector string) (string, error) {
if cfg == nil {
return "", nil
}
selector = strings.TrimSpace(selector)
if selector == "" {
return "", nil
}
if len(profilesForCorpID(cfg, selector)) > 0 {
return selector, nil
}
orgIDs := make(map[string]struct{})
for i := range cfg.Profiles {
if strings.TrimSpace(cfg.Profiles[i].CorpName) != selector {
continue
}
orgIDs[strings.TrimSpace(cfg.Profiles[i].CorpID)] = struct{}{}
}
if len(orgIDs) == 0 {
return "", nil
}
if len(orgIDs) == 1 {
for corpID := range orgIDs {
return corpID, nil
}
}
candidates := make([]string, 0, len(orgIDs))
for corpID := range orgIDs {
candidates = append(candidates, corpID)
}
sort.Strings(candidates)
return "", fmt.Errorf(
"organization name %q is ambiguous; use one of: %s",
selector,
strings.Join(candidates, ", "),
)
}
func resolveOrganizationDefault(cfg *ProfilesConfig, corpID, displaySelector string, profiles []*Profile) (*Profile, bool, error) {
if len(profiles) == 0 {
return nil, false, fmt.Errorf("organization %q not found", displaySelector)
}
if exact := exactProfileSelectorForCorp(cfg, corpID, cfg.OrgCurrentProfiles[corpID]); exact != "" {
selectedCorpID, userID, _ := ParseIdentitySelector(exact)
if p := findExactProfile(cfg, selectedCorpID, userID); p != nil {
return p, false, nil
}
}
if unresolved := unresolvedProfileForCorp(cfg, corpID); unresolved != nil {
// With no exact organization-current selection, the organization slot
// belongs to the sole unresolved historical account. Do not choose an
// arbitrary exact identity merely because it shares the corpId.
return unresolved, false, nil
}
if len(profiles) == 1 {
return profiles[0], false, nil
}
return nil, false, fmt.Errorf(
"organization %q has multiple accounts and no current account; use one of: %s",
displaySelector,
strings.Join(profileSelectorCandidates(profiles), ", "),
)
}
func profileSelectorCandidates(profiles []*Profile) []string {
cfg := &ProfilesConfig{Profiles: make([]Profile, 0, len(profiles))}
for _, profile := range profiles {
if profile != nil {
cfg.Profiles = append(cfg.Profiles, *profile)
}
}
candidates := make([]string, 0, len(profiles))
for _, p := range profiles {
if p == nil {
continue
}
candidates = append(candidates, storedProfileSelector(cfg, p))
}
sort.Strings(candidates)
return candidates
}
func exactProfileSelectorForCorp(cfg *ProfilesConfig, corpID, selector string) string {
selectedCorpID, userID, exact := ParseIdentitySelector(selector)
if !exact || strings.TrimSpace(selectedCorpID) != strings.TrimSpace(corpID) {
return ""
}
if p := findExactProfile(cfg, selectedCorpID, userID); p != nil {
return ProfileSelector(*p)
}
return ""
}
func canonicalStoredSelector(cfg *ProfilesConfig, selector string) string {
selector = strings.TrimSpace(selector)
if selector == "" {
return ""
}
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
if profile := unresolvedProfileForCorp(cfg, corpID); profile != nil {
return storedProfileSelector(cfg, profile)
}
return ""
}
if corpID, userID, exact := ParseIdentitySelector(selector); exact {
if p := findExactProfile(cfg, corpID, userID); p != nil {
return ProfileSelector(*p)
}
// A colon-containing legacy local name is recoverable only when it does
// not name a real exact identity. Exact corpId:userId always wins.
if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil {
return storedProfileSelector(cfg, profile)
}
return ""
}
// Older multi-account writers stored the unresolved profile's local name.
// Recover it only when no profile gives the same text organization-selector
// meaning. CorpId and CorpName have always outranked local names in the
// public resolver; migration must preserve that precedence instead of
// silently redirecting one organization's selector to another blank profile.
if !selectorConflictsWithOrganizationGrammar(cfg, selector) {
if profile := unresolvedProfileForLocalName(cfg, selector); profile != nil {
return storedProfileSelector(cfg, profile)
}
}
if profiles := profilesForCorpID(cfg, selector); len(profiles) > 0 {
if exact := exactProfileSelectorForCorp(cfg, selector, cfg.OrgCurrentProfiles[selector]); exact != "" {
return exact
}
if len(profiles) == 1 {
return storedProfileSelector(cfg, profiles[0])
}
return ""
}
p, _, err := resolveProfileSelection("", cfg, selector)
if err != nil || p == nil {
return ""
}
return storedProfileSelector(cfg, p)
}
func selectorConflictsWithOrganizationGrammar(cfg *ProfilesConfig, selector string) bool {
if cfg == nil {
return false
}
selector = strings.TrimSpace(selector)
for i := range cfg.Profiles {
if strings.TrimSpace(cfg.Profiles[i].CorpID) == selector ||
strings.TrimSpace(cfg.Profiles[i].CorpName) == selector {
return true
}
}
return false
}
func setOrgCurrentProfile(cfg *ProfilesConfig, corpID, selector string) {
if cfg == nil {
return
}
exact := exactProfileSelectorForCorp(cfg, corpID, selector)
if exact == "" {
return
}
if cfg.OrgCurrentProfiles == nil {
cfg.OrgCurrentProfiles = make(map[string]string)
}
cfg.OrgCurrentProfiles[strings.TrimSpace(corpID)] = exact
}
func uniqueProfileCorpIDs(cfg *ProfilesConfig) []string {
if cfg == nil {
return nil
}
seen := make(map[string]bool)
result := make([]string, 0)
for _, p := range cfg.Profiles {
corpID := strings.TrimSpace(p.CorpID)
if corpID == "" || seen[corpID] {
continue
}
seen[corpID] = true
result = append(result, corpID)
}
return result
}
func profileIndexByIdentity(cfg *ProfilesConfig, corpID, userID string) int {
if cfg == nil {
return -1
}
for i := range cfg.Profiles {
if sameProfileIdentity(cfg.Profiles[i].CorpID, cfg.Profiles[i].UserID, corpID, userID) {
return i
}
}
return -1
}
func legacyProfileIndexByCorpID(cfg *ProfilesConfig, corpID string) int {
if cfg == nil {
return -1
}
match := -1
for i := range cfg.Profiles {
if strings.TrimSpace(cfg.Profiles[i].CorpID) != strings.TrimSpace(corpID) || strings.TrimSpace(cfg.Profiles[i].UserID) != "" {
continue
}
if match >= 0 {
return -1
}
match = i
}
return match
}
func findExactProfile(cfg *ProfilesConfig, corpID, userID string) *Profile {
idx := profileIndexByIdentity(cfg, corpID, userID)
if idx < 0 {
return nil
}
return &cfg.Profiles[idx]
}
func profilesForCorpID(cfg *ProfilesConfig, corpID string) []*Profile {
if cfg == nil {
return nil
}
corpID = strings.TrimSpace(corpID)
result := make([]*Profile, 0)
for i := range cfg.Profiles {
if strings.TrimSpace(cfg.Profiles[i].CorpID) == corpID {
result = append(result, &cfg.Profiles[i])
}
}
return result
}
func unresolvedProfileForCorp(cfg *ProfilesConfig, corpID string) *Profile {
if cfg == nil {
return nil
}
corpID = strings.TrimSpace(corpID)
for i := range cfg.Profiles {
profile := &cfg.Profiles[i]
if strings.TrimSpace(profile.CorpID) == corpID && strings.TrimSpace(profile.UserID) == "" {
return profile
}
}
return nil
}
func unresolvedProfileForLocalName(cfg *ProfilesConfig, name string) *Profile {
if cfg == nil {
return nil
}
name = strings.TrimSpace(name)
if name == "" {
return nil
}
var match *Profile
for i := range cfg.Profiles {
profile := &cfg.Profiles[i]
if strings.TrimSpace(profile.UserID) != "" || strings.TrimSpace(profile.Name) != name ||
len(profilesForCorpID(cfg, profile.CorpID)) <= 1 {
continue
}
if match != nil {
return nil
}
match = profile
}
return match
}
// When a blank profile coexists with exact accounts, the organization slot is
// that unresolved profile's only canonical credential. Exact identities must
// remain in their identity slots and may still become global current without
// overwriting the organization slot.
func shouldSyncOrganizationMirror(cfg *ProfilesConfig, profile Profile) bool {
return strings.TrimSpace(profile.UserID) == "" || unresolvedProfileForCorp(cfg, profile.CorpID) == nil
}
// validateIdentityOnlyProfileToken verifies the canonical token slot before a
// profile selection is persisted. This path is used only when an unresolved
// profile owns the organization slot, so an exact identity must not fall back
// to that slot. It is deliberately read-only: a rejected switch leaves every
// selection pointer and compatibility mirror untouched.
func validateIdentityOnlyProfileToken(profile Profile) error {
corpID := strings.TrimSpace(profile.CorpID)
userID := strings.TrimSpace(profile.UserID)
if corpID == "" || userID == "" {
return ErrTokenDataNotFound
}
data, err := profilesLoadIdentity(corpID, userID)
if err != nil {
return fmt.Errorf("load token for profile %q: %w", ProfileSelector(profile), err)
}
if data == nil {
return fmt.Errorf("load token for profile %q: %w", ProfileSelector(profile), ErrTokenDataNotFound)
}
if !sameProfileIdentity(data.CorpID, data.UserID, corpID, userID) {
return fmt.Errorf(
"token in profile slot %q belongs to %q; identity does not match selected profile",
ProfileSelector(profile),
profileSelector(data.CorpID, data.UserID),
)
}
return nil
}
func profileSelectorReferenceExists(cfg *ProfilesConfig, selector string) bool {
if cfg == nil {
return false
}
selector = strings.TrimSpace(selector)
if selector == "" {
return false
}
if corpID, unresolved := parseUnresolvedProfileSelector(selector); unresolved {
return unresolvedProfileForCorp(cfg, corpID) != nil
}
if unresolvedProfileForLocalName(cfg, selector) != nil {
return true
}
if corpID, userID, exact := ParseIdentitySelector(selector); exact {
if findExactProfile(cfg, corpID, userID) != nil {
return true
}
for i := range cfg.Profiles {
p := &cfg.Profiles[i]
orgMatches := strings.TrimSpace(p.CorpID) == corpID || strings.TrimSpace(p.CorpName) == corpID
accountMatches := strings.TrimSpace(p.UserID) == userID || strings.TrimSpace(p.UserName) == userID
if orgMatches && accountMatches {
return true
}
}
return false
}
if len(profilesForCorpID(cfg, selector)) > 0 {
return true
}
for i := range cfg.Profiles {
if strings.TrimSpace(cfg.Profiles[i].CorpName) == selector ||
strings.TrimSpace(cfg.Profiles[i].Name) == selector {
return true
}
}
return false
}
func profileIdentityKey(corpID, userID string) string {
return strings.TrimSpace(corpID) + "\x00" + strings.TrimSpace(userID)
}
func sameProfileIdentity(leftCorpID, leftUserID, rightCorpID, rightUserID string) bool {
return profileIdentityKey(leftCorpID, leftUserID) == profileIdentityKey(rightCorpID, rightUserID)
}
func shortCorpID(corpID string) string {
corpID = strings.TrimSpace(corpID)
if len(corpID) <= 8 {
return corpID
}
return corpID[len(corpID)-8:]
}
func shortProfileID(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 8 {
return value
}
return value[len(value)-8:]
}