// 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 ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "os" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) var ( tokenJSONMarshalIndent = json.MarshalIndent tokenJSONMarshal = json.Marshal tokenMkdirAll = os.MkdirAll tokenReadFile = os.ReadFile tokenWriteFile = os.WriteFile tokenRename = os.Rename tokenRemove = os.Remove tokenGlob = filepath.Glob tokenSaveKeychainForCorpID = SaveTokenDataKeychainForCorpID tokenSaveKeychainForIdentity = SaveTokenDataKeychainForIdentity tokenSaveKeychain = SaveTokenDataKeychain tokenLoadKeychainForCorpID = LoadTokenDataKeychainForCorpID tokenLoadKeychainIdentity = LoadTokenDataKeychainForIdentity tokenLoadKeychain = LoadTokenDataKeychain tokenKeychainExists = TokenDataExistsKeychain tokenDeleteKeychainForCorpID = DeleteTokenDataKeychainForCorpID tokenDeleteKeychainIdentity = DeleteTokenDataKeychainForIdentity tokenDeleteKeychain = DeleteTokenDataKeychain tokenRemoveAuthTokenEntries = keychain.RemoveAuthTokenEntries tokenLoadSecure = LoadSecureTokenData tokenDeleteSecure = DeleteSecureData tokenResolveProfile = func(configDir, selector string) (*Profile, error) { profile, _, err := resolveProfileForLoadLocked(configDir, selector) return profile, err } tokenResolveDeletion = resolveProfileDeletionSelection tokenResolveSelection = resolveProfileSelection tokenUpsertProfile = upsertProfileFromTokenWithCurrentLocked tokenRemoveProfile = removeProfileLocked tokenSyncLegacyMirror = syncLegacyTokenMirrorLocked tokenSyncOrganizationMirror = syncOrganizationTokenMirrorForProfile tokenLoadProfiles = LoadProfiles tokenEnsureProfilesMigration = ensureProfilesMigrationLocked tokenSaveProfiles = SaveProfiles tokenWriteMarker = WriteTokenMarker tokenWriteManualMarker = WriteManualTokenMarker tokenDeleteMarker = DeleteTokenMarker tokenParseURL = url.Parse tokenNewRequest = http.NewRequestWithContext tokenDefaultConfigDir = getDefaultConfigDir tokenLoadData = LoadTokenData tokenRevokeURL = GetRevokeTokenURL tokenMCPBaseURL = GetMCPBaseURL tokenLogoutURL = LogoutURL tokenLogoutContinueURL = LogoutContinueURL tokenLogoutHTTPClient = &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} tokenRevokeHTTPClient = &http.Client{Timeout: 10 * time.Second} ) // TokenData holds the OAuth token set persisted to disk. type TokenData struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` PersistentCode string `json:"persistent_code"` ExpiresAt time.Time `json:"expires_at"` RefreshExpAt time.Time `json:"refresh_expires_at"` CorpID string `json:"corp_id"` UserID string `json:"user_id,omitempty"` UserName string `json:"user_name,omitempty"` CorpName string `json:"corp_name,omitempty"` ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh UpdatedAt string `json:"updated_at,omitempty"` Source string `json:"source,omitempty"` LoginRegion string `json:"login_region,omitempty"` // LegacyOrgScopedProfile is an in-memory destination for an explicitly // matched historical profile whose userId was never resolved. It is never // persisted as token material. LegacyOrgScopedProfile string `json:"-"` // FreshAuthorization distinguishes a new OAuth/device exchange from a // refresh of the credential already selected locally. Persistence uses this // transient marker to reject ambiguous UID-less logins without breaking // legitimate refreshes of unresolved accounts. FreshAuthorization bool `json:"-"` } // tokenPersistenceWritePlan is the single source of truth for deciding which // credential slots a token publication can touch. Both the write path and its // read-only preflight must use this plan so a slot cannot be newly written // without first being checked for unreadable data. // // The plan is intentionally pure: callers supply the already-loaded profile // registry and process-local runtime selector, and no storage is read or // mutated while the decision is made. type tokenPersistenceWritePlan struct { CorpID string UserID string RuntimeSelector string PersistenceSelector string ExactSelector string MakeCurrent bool ExistingIdentity bool UpgradesLegacyProfile bool PreserveUnresolvedOrganization bool WriteIdentity bool WriteOrganization bool WriteGlobal bool } func planTokenPersistenceWrites( cfg *ProfilesConfig, data *TokenData, runtimeSelector string, ) tokenPersistenceWritePlan { plan := tokenPersistenceWritePlan{ RuntimeSelector: strings.TrimSpace(runtimeSelector), // Manual and organization-bound publications both snapshot the global // compatibility slot before they can replace or resynchronize it. WriteGlobal: true, } if data == nil { return plan } plan.CorpID = strings.TrimSpace(data.CorpID) plan.UserID = strings.TrimSpace(data.UserID) if plan.CorpID == "" { return plan } plan.PersistenceSelector = plan.RuntimeSelector if plan.PersistenceSelector == "" && plan.UserID == "" { plan.PersistenceSelector = strings.TrimSpace(data.LegacyOrgScopedProfile) } plan.MakeCurrent = plan.PersistenceSelector == "" plan.ExactSelector = profileSelector(plan.CorpID, plan.UserID) plan.ExistingIdentity = profileIndexByIdentity(cfg, plan.CorpID, plan.UserID) >= 0 plan.UpgradesLegacyProfile = !plan.ExistingIdentity && plan.UserID != "" && len(profilesForCorpID(cfg, plan.CorpID)) == 1 && legacyProfileIndexByCorpID(cfg, plan.CorpID) >= 0 plan.PreserveUnresolvedOrganization = plan.UserID != "" && unresolvedProfileForCorp(cfg, plan.CorpID) != nil && !plan.UpgradesLegacyProfile plan.WriteIdentity = plan.UserID != "" orgCurrentSelector := "" if cfg != nil { orgCurrentSelector = cfg.OrgCurrentProfiles[plan.CorpID] } // A sole unresolved profile is being completed in place during explicit // reauthorization. Its organization slot must move with the newly exact // identity even when an explicit runtime selector keeps it from becoming // process-global current. plan.WriteOrganization = plan.UserID == "" || plan.UpgradesLegacyProfile || (!plan.PreserveUnresolvedOrganization && (plan.MakeCurrent || exactProfileSelectorForCorp( cfg, plan.CorpID, orgCurrentSelector, ) == plan.ExactSelector)) return plan } // IsAccessTokenValid returns true if the access token has not expired. func (t *TokenData) IsAccessTokenValid() bool { if t == nil || t.AccessToken == "" { return false } // Give 5-minute buffer before actual expiry. return time.Now().Before(t.ExpiresAt.Add(-5 * time.Minute)) } // IsRefreshTokenValid returns true if the refresh token has not expired. func (t *TokenData) IsRefreshTokenValid() bool { if t == nil || t.RefreshToken == "" { return false } return time.Now().Before(t.RefreshExpAt) } // HasPersistentCode returns true if a persistent code is available. func (t *TokenData) HasPersistentCode() bool { return t != nil && t.PersistentCode != "" } const tokenJSONFile = "token.json" // TokenMarker is a lightweight file the host application reads to detect // whether the CLI has a valid token without accessing the keychain. type TokenMarker struct { UpdatedAt string `json:"updated_at"` ManualToken bool `json:"manual_token,omitempty"` // Revision changes on every credential publication. Runtime token caches // use it as a cheap cross-process invalidation signal without reading the // platform keychain on every request. Revision string `json:"revision,omitempty"` } // WriteTokenMarker writes a token.json marker containing only an updated_at // timestamp. The host application uses this file's presence and mtime to // decide whether it needs to trigger a new auth exchange. func WriteTokenMarker(configDir string) error { return writeTokenMarker(configDir, false) } // WriteManualTokenMarker marks the legacy global keychain slot as an explicit // `auth login --token` credential. The additive field keeps older hosts, which // only inspect token.json presence and mtime, fully compatible. func WriteManualTokenMarker(configDir string) error { return writeTokenMarker(configDir, true) } func writeTokenMarker(configDir string, manual bool) error { marker := TokenMarker{ UpdatedAt: time.Now().Format(time.RFC3339), ManualToken: manual, Revision: uuid.NewString(), } data, _ := tokenJSONMarshalIndent(marker, "", " ") if err := tokenMkdirAll(configDir, 0o700); err != nil { return err } tmp := filepath.Join(configDir, tokenJSONFile+"."+uuid.New().String()+".tmp") if err := tokenWriteFile(tmp, data, 0o600); err != nil { return err } return tokenRename(tmp, filepath.Join(configDir, tokenJSONFile)) } // ReadTokenMarkerRevision returns the current credential publication revision. // Existing markers without a revision remain readable, but callers must avoid // caching them because they cannot prove that the credential is unchanged. func ReadTokenMarkerRevision(configDir string) (revision string, present bool, err error) { data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile)) if err != nil { if os.IsNotExist(err) { return "", false, nil } return "", false, fmt.Errorf("read token marker: %w", err) } var marker TokenMarker if err := json.Unmarshal(data, &marker); err != nil { // The marker is only a cache-coherency hint. A malformed historical or // externally modified marker must disable caching, not make an otherwise // valid credential unusable. return "", true, nil } return strings.TrimSpace(marker.Revision), true, nil } func manualTokenMarkerActive(configDir string) (bool, error) { data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile)) if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } var marker TokenMarker if err := json.Unmarshal(data, &marker); err != nil { // Historical hosts only require the marker's presence. A malformed old // marker must not make profile authentication unusable. return false, nil } return marker.ManualToken, nil } // DeleteTokenMarker removes the token.json marker file. func DeleteTokenMarker(configDir string) error { if err := tokenRemove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) { return err } return nil } // SaveTokenData persists TokenData under the auth dual lock. When an edition // hook (SaveToken) is registered, the locked write delegates to that hook; // otherwise it falls back to the default keychain-based storage. func SaveTokenData(configDir string, data *TokenData) error { return withProfilesLock(configDir, func() error { return saveTokenDataLocked(configDir, data) }) } // SaveLoginTokenData is the safe persistence boundary for credentials produced // by a new login entry point (OAuth/device/PAT authorization or --token). It // repairs a uniquely recoverable half-migrated legacy login before any global // mirror can be replaced, marks the incoming credential as a fresh // authorization for UID-less account isolation, and preflights every slot the // write plan can touch. // // Refresh paths must continue to use SaveTokenData after their refresh-specific // preflight; treating a refresh as a fresh authorization would incorrectly // reject the selected unresolved account in a multi-account organization. func SaveLoginTokenData(configDir string, data *TokenData) error { if data == nil { return fmt.Errorf("token data is empty") } if err := prepareLoginPersistence(configDir); err != nil { return fmt.Errorf("local login state cannot be safely updated: %w", err) } data.FreshAuthorization = true if err := preflightTokenWritePersistence(configDir, data); err != nil { return fmt.Errorf("local login state cannot be safely updated: %w", err) } return SaveTokenData(configDir, data) } // saveTokenDataLocked performs the keychain + profiles.json + legacy mirror // writes assuming the auth dual-layer lock is already held. Callers that // already hold the lock (OAuthProvider refresh path, the legacy secure->keychain // migration in LoadTokenDataForProfile) must use this instead of SaveTokenData // to avoid deadlocking on the non-reentrant lock. func saveTokenDataLocked(configDir string, data *TokenData) error { if h := edition.Get(); h.SaveToken != nil { return saveTokenViaHook(h, configDir, data) } if data != nil && strings.TrimSpace(data.CorpID) != "" { corpID := strings.TrimSpace(data.CorpID) userID := strings.TrimSpace(data.UserID) cfg, err := tokenLoadProfiles(configDir) if err != nil { return err } // A login may be the first operation after upgrading. Finish a v1 // registry migration before an upsert can raise profiles.json to v2; // otherwise untouched organizations would permanently lose their chance // to receive exact identity token slots. Do not re-run v2 repair here: // refresh has already rotated the remote credential at this point, and an // unrelated damaged identity slot must not prevent the new token from // being committed. Normal load/preflight paths repair v2 before exchange. if cfg.Version < profilesVersion { if err := tokenEnsureProfilesMigration(configDir); err != nil { return err } cfg, err = tokenLoadProfiles(configDir) if err != nil { return err } } if err := ensureProfilesWritable(cfg); err != nil { return err } plan := planTokenPersistenceWrites(cfg, data, RuntimeProfile()) if err := validateTokenPersistenceWritePlan(cfg, data, plan); err != nil { return err } logging.AuthDebug( "auth.token.persist.plan", "corp_id", corpID, "user_id", userID, "user_name", strings.TrimSpace(data.UserName), "identity_selector", plan.ExactSelector, "existing_identity", plan.ExistingIdentity, "upgrades_legacy_profile", plan.UpgradesLegacyProfile, "profiles_before", len(cfg.Profiles), "runtime_profile", plan.RuntimeSelector, "persistence_profile", plan.PersistenceSelector, "write_identity_slot", plan.WriteIdentity, "write_org_mirror", plan.WriteOrganization, "write_global_mirror", plan.WriteGlobal, "publish_incoming_global", plan.MakeCurrent, ) snapshot, err := snapshotTokenPersistence( configDir, cfg, plan.CorpID, plan.UserID, plan.WriteOrganization, ) if err != nil { return err } preserveManualDefault := !plan.MakeCurrent && snapshot.marker.known && snapshot.marker.exists && snapshot.marker.manual rollback := func(operationErr error) error { if rollbackErr := restoreTokenPersistence(configDir, snapshot); rollbackErr != nil { return errors.Join(operationErr, fmt.Errorf("rollback token persistence: %w", rollbackErr)) } return operationErr } if plan.WriteIdentity { if err := tokenSaveKeychainForIdentity(corpID, userID, data); err != nil { return rollback(err) } } if err := tokenUpsertProfile(configDir, data, plan.MakeCurrent); err != nil { return rollback(err) } if plan.WriteOrganization { if err := tokenSaveKeychainForCorpID(corpID, data); err != nil { return rollback(err) } } if plan.WriteGlobal { if plan.MakeCurrent { if err := tokenSaveKeychain(data); err != nil { return rollback(err) } } else if !preserveManualDefault { if err := tokenSyncLegacyMirror(configDir); err != nil { return rollback(err) } } } if preserveManualDefault { if err := tokenWriteManualMarker(configDir); err != nil { return rollback(err) } } else if err := tokenWriteMarker(configDir); err != nil { return rollback(err) } logging.AuthDebug( "auth.token.persist.done", "corp_id", corpID, "user_id", userID, "user_name", strings.TrimSpace(data.UserName), "identity_selector", plan.ExactSelector, "write_identity_slot", plan.WriteIdentity, "write_org_mirror", plan.WriteOrganization, "write_global_mirror", plan.WriteGlobal && !preserveManualDefault, "publish_incoming_global", plan.MakeCurrent, ) return nil } legacySnapshot, err := snapshotTokenSlot(tokenLoadKeychain) if err != nil { return err } markerSnapshot, err := snapshotTokenMarker(configDir) if err != nil { return err } if err := tokenSaveKeychain(data); err != nil { return err } if err := tokenWriteManualMarker(configDir); err != nil { var rollbackErr error if restoreErr := restoreTokenSlot( legacySnapshot, tokenSaveKeychain, tokenDeleteKeychain, ); restoreErr != nil { rollbackErr = errors.Join(rollbackErr, restoreErr) } if restoreErr := restoreTokenMarker(configDir, markerSnapshot); restoreErr != nil { rollbackErr = errors.Join(rollbackErr, restoreErr) } if rollbackErr != nil { return errors.Join(err, fmt.Errorf("rollback manual token persistence: %w", rollbackErr)) } return err } return nil } func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error { jsonData, err := tokenJSONMarshalIndent(data, "", " ") if err != nil { return fmt.Errorf("marshaling token data for hook: %w", err) } return h.SaveToken(configDir, jsonData) } // LoadTokenData reads TokenData. When an edition hook (LoadToken) is // registered, it delegates entirely to the hook; otherwise it falls back // to keychain with legacy .data migration. func LoadTokenData(configDir string) (*TokenData, error) { return LoadTokenDataForProfile(configDir, RuntimeProfile()) } // LoadTokenDataForProfile reads TokenData for a profile selector without mutating // currentProfile. Empty selector follows the default resolution chain. func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) { if h := edition.Get(); h.LoadToken != nil { if strings.TrimSpace(profile) != "" { return nil, fmt.Errorf("profile selection is not supported by the current auth backend") } jsonData, err := h.LoadToken(configDir) if err != nil { return nil, err } var td TokenData if err := json.Unmarshal(jsonData, &td); err != nil { return nil, fmt.Errorf("parsing token data from hook: %w", err) } return &td, nil } var result *TokenData err := withProfilesLock(configDir, func() error { var loadErr error result, loadErr = loadTokenDataForProfileLocked(configDir, profile) return loadErr }) return result, err } func loadTokenDataForProfileLocked(configDir, profile string) (*TokenData, error) { // Default: keychain with legacy .data migration if strings.TrimSpace(profile) == "" { manual, err := manualTokenMarkerActive(configDir) if err != nil { return nil, err } if manual { data, loadErr := tokenLoadKeychain() if loadErr == nil && data != nil && strings.TrimSpace(data.CorpID) == "" { return data, nil } if loadErr != nil && !errors.Is(loadErr, ErrTokenDataNotFound) { return nil, loadErr } } } selected, err := tokenResolveProfile(configDir, profile) if err != nil { return nil, err } if selected != nil { data, err := tokenLoadProfileIdentity(*selected) if err == nil { return data, nil } if strings.TrimSpace(profile) != "" || !errors.Is(err, ErrTokenDataNotFound) { return nil, err } // No explicit --profile: `selected` is the resolved current/primary // profile. Only fall back to the legacy single slot when it belongs to // the SAME org; otherwise surface the error instead of silently acting // as a different organization (the legacy mirror may have drifted). if legacy, lerr := tokenLoadKeychain(); lerr == nil && legacy != nil && strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) && strings.TrimSpace(legacy.UserID) == strings.TrimSpace(selected.UserID) { return legacy, nil } else if lerr != nil && !errors.Is(lerr, ErrTokenDataNotFound) { return nil, lerr } return nil, err } cfg, err := tokenLoadProfiles(configDir) if err != nil { return nil, err } if cfg != nil && cfg.Version >= profilesVersion { return nil, ErrTokenDataNotFound } if tokenKeychainExists() { return tokenLoadKeychain() } data, err := tokenLoadSecure(configDir) if err != nil { return nil, err } // One-time legacy secure-store -> keychain migration. This read path may run // while the refresh lock is already held, so use the lock-free saver. if err := saveTokenDataLocked(configDir, data); err == nil { _ = tokenDeleteSecure(configDir) } return data, nil } func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) { corpID := strings.TrimSpace(profile.CorpID) userID := strings.TrimSpace(profile.UserID) if strings.TrimSpace(profile.UserID) == "" { data, err := tokenLoadKeychainForCorpID(corpID) if err != nil { return nil, err } if data == nil { return nil, ErrTokenDataNotFound } if strings.TrimSpace(data.CorpID) != corpID { return nil, fmt.Errorf( "organization token mirror for corpId %q contains token for corpId %q; cannot use it for unresolved profile %q", corpID, data.CorpID, profile.Name, ) } 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", corpID, data.UserID, profile.Name, ) } return data, nil } data, err := tokenLoadKeychainIdentity(corpID, userID) if err == nil { if data == nil { return nil, ErrTokenDataNotFound } if strings.TrimSpace(data.CorpID) != corpID || strings.TrimSpace(data.UserID) != userID { return nil, fmt.Errorf( "identity token slot %q contains token for %q; cannot use it for profile %q", TokenAccountForIdentity(corpID, userID), profileSelector(data.CorpID, data.UserID), ProfileSelector(profile), ) } return data, nil } if !errors.Is(err, ErrTokenDataNotFound) { return nil, err } orgData, orgErr := tokenLoadKeychainForCorpID(corpID) if orgErr != nil { if errors.Is(orgErr, ErrTokenDataNotFound) { return nil, err } return nil, orgErr } if orgData == nil { return nil, err } if strings.TrimSpace(orgData.CorpID) != corpID { return nil, fmt.Errorf( "organization token mirror for corpId %q contains token for corpId %q; cannot use it for profile %q", corpID, orgData.CorpID, ProfileSelector(profile), ) } if strings.TrimSpace(orgData.UserID) == "" { return nil, fmt.Errorf("organization token mirror for corpId %q has no userId; cannot use it for profile %q", corpID, ProfileSelector(profile)) } if strings.TrimSpace(orgData.UserID) != userID { return nil, err } if saveErr := tokenSaveKeychainForIdentity(corpID, userID, orgData); saveErr != nil { return nil, saveErr } return orgData, nil } // validateTokenPersistenceWritePlan prevents a freshly authorized UID-less // credential from being attached to an existing unresolved sibling merely // because both accounts share a corpId. An explicit selector is a storage // boundary, but it is not proof that an exact account completed OAuth. The only // safe overwrite is when that selector itself resolves to the existing // unresolved profile. A blank selector remains allowed for ordinary refreshes // of the already-selected unresolved token. func validateTokenPersistenceWritePlan( cfg *ProfilesConfig, data *TokenData, plan tokenPersistenceWritePlan, ) error { if data == nil || plan.CorpID == "" || plan.UserID != "" { return nil } unresolved := unresolvedProfileForCorp(cfg, plan.CorpID) if unresolved == nil { return nil } targetSelector := plan.RuntimeSelector if targetSelector == "" { targetSelector = strings.TrimSpace(data.LegacyOrgScopedProfile) } if targetSelector == "" { if data.FreshAuthorization && len(profilesForCorpID(cfg, plan.CorpID)) > 1 { return fmt.Errorf( "refusing to save a fresh UID-less token over existing unresolved profile %q in multi-account organization %q; retry with that unresolved profile selector or require server-provided userId", storedProfileSelector(cfg, unresolved), plan.CorpID, ) } return nil } selected, _, err := resolveProfileSelection("", cfg, targetSelector) if err == nil && selected != nil && strings.TrimSpace(selected.CorpID) == plan.CorpID && strings.TrimSpace(selected.UserID) == "" { return nil } return fmt.Errorf( "refusing to save UID-less token selected as profile %q over existing unresolved profile %q in organization %q; retry with the unresolved profile selector or require server-provided userId", targetSelector, storedProfileSelector(cfg, unresolved), plan.CorpID, ) } // DeleteTokenData removes token data. Edition hooks and the default keychain // path are both serialized with refresh through the auth dual lock. func DeleteTokenData(configDir string) error { return DeleteTokenDataForProfile(configDir, RuntimeProfile()) } // DeleteTokenDataForProfile removes one profile's token data. Empty selector // removes the current/default profile, falling back to legacy single-slot auth. func DeleteTokenDataForProfile(configDir, profile string) error { if h := edition.Get(); h.DeleteToken != nil { if strings.TrimSpace(profile) != "" { return fmt.Errorf("profile selection is not supported by the current auth backend") } return withProfilesLock(configDir, func() error { return h.DeleteToken(configDir) }) } return withProfilesLock(configDir, func() error { return deleteTokenDataForProfileLocked(configDir, profile) }) } func deleteTokenDataForProfileLocked(configDir, profile string) error { if strings.TrimSpace(profile) == "" { manual, err := manualTokenMarkerActive(configDir) if err != nil { return err } if manual { return deleteManualTokenDataLocked(configDir) } } if err := profilesEnsureMigration(configDir); err != nil { return err } cfg, err := tokenLoadProfiles(configDir) if err != nil { return err } effectiveSelector := strings.TrimSpace(profile) if effectiveSelector == "" { effectiveSelector = strings.TrimSpace(cfg.CurrentProfile) } var selected *Profile exact := false if effectiveSelector != "" { selected, exact, err = tokenResolveDeletion(cfg, effectiveSelector) if err != nil { return err } } if selected != nil { removed := *selected originalCfg := cloneProfilesConfig(cfg) identitySnapshots, err := snapshotDeletionIdentities(cfg, removed, exact) if err != nil { return err } orgSnapshot := snapshotTokenSlotForDeletion(func() (*TokenData, error) { return tokenLoadKeychainForCorpID(removed.CorpID) }) legacySnapshot := snapshotTokenSlotForDeletion(tokenLoadKeychain) markerSnapshot := snapshotTokenMarkerForDeletion(configDir) // Clean the deprecated secure-store copy before changing the profile // transaction. A cleanup failure therefore leaves all current metadata // and keychain slots untouched. if err := tokenDeleteSecure(configDir); err != nil { return err } removeSelector := removed.CorpID orgCurrent := false if exact { if strings.TrimSpace(removed.UserID) == "" { // A blank profile is exact only when it was selected by its unique // local name. Converting it back to corpId here would turn a // one-profile logout into whole-organization deletion. removeSelector = effectiveSelector orgCurrent = true } else { removeSelector = ProfileSelector(removed) orgCurrent = exactProfileSelectorForCorp( cfg, removed.CorpID, cfg.OrgCurrentProfiles[removed.CorpID], ) == ProfileSelector(removed) } } if _, err := tokenRemoveProfile(configDir, removeSelector); err != nil { return err } rollback := func(operationErr error) error { if rollbackErr := restoreProfileDeletion( configDir, originalCfg, identitySnapshots, removed.CorpID, orgSnapshot, legacySnapshot, markerSnapshot, ); rollbackErr != nil { return errors.Join(operationErr, fmt.Errorf("rollback profile deletion: %w", rollbackErr)) } return operationErr } if !exact || orgCurrent { updated, loadErr := tokenLoadProfiles(configDir) if loadErr != nil { return rollback(loadErr) } replacementSelector := updated.OrgCurrentProfiles[removed.CorpID] preserveUnresolvedOrg := unresolvedProfileForCorp(updated, removed.CorpID) != nil if exact && replacementSelector != "" && !preserveUnresolvedOrg { replacement, _, resolveErr := tokenResolveSelection(configDir, updated, replacementSelector) if resolveErr != nil { return rollback(resolveErr) } if err := tokenSyncOrganizationMirror(*replacement); err != nil { return rollback(err) } } else if !preserveUnresolvedOrg { if err := tokenDeleteKeychainForCorpID(removed.CorpID); err != nil { return rollback(err) } } } preserveManualDefault := markerSnapshot.known && markerSnapshot.exists && markerSnapshot.manual if !preserveManualDefault { if err := tokenSyncLegacyMirror(configDir); err != nil { return rollback(err) } } for _, snapshot := range identitySnapshots { if err := tokenDeleteKeychainIdentity(snapshot.profile.CorpID, snapshot.profile.UserID); err != nil { return rollback(err) } } return nil } keychainErr := tokenDeleteKeychain() legacyErr := tokenDeleteSecure(configDir) markerErr := tokenDeleteMarker(configDir) if keychainErr != nil { return keychainErr } if legacyErr != nil { return legacyErr } return markerErr } func deleteManualTokenDataLocked(configDir string) error { legacySnapshot := snapshotTokenSlotForDeletion(tokenLoadKeychain) if err := tokenDeleteSecure(configDir); err != nil { return err } if err := tokenDeleteKeychain(); err != nil { return err } if err := tokenDeleteMarker(configDir); err != nil { if legacySnapshot.known { if rollbackErr := restoreTokenSlot( legacySnapshot, tokenSaveKeychain, tokenDeleteKeychain, ); rollbackErr != nil { return errors.Join(err, fmt.Errorf("rollback manual token deletion: %w", rollbackErr)) } } return err } return nil } type deletionIdentitySnapshot struct { profile Profile token *TokenData } type tokenSlotSnapshot struct { token *TokenData known bool exists bool } type tokenMarkerSnapshot struct { known bool exists bool manual bool } type tokenPersistenceSnapshot struct { profiles *ProfilesConfig corpID string userID string identity tokenSlotSnapshot org tokenSlotSnapshot legacy tokenSlotSnapshot marker tokenMarkerSnapshot } func cloneProfilesConfig(cfg *ProfilesConfig) *ProfilesConfig { if cfg == nil { return nil } cloned := *cfg cloned.Profiles = append([]Profile(nil), cfg.Profiles...) for i := range cloned.Profiles { cloned.Profiles[i].AuthorizedDomains = append( []string(nil), cloned.Profiles[i].AuthorizedDomains..., ) } if cfg.OrgCurrentProfiles != nil { cloned.OrgCurrentProfiles = make(map[string]string, len(cfg.OrgCurrentProfiles)) for corpID, selector := range cfg.OrgCurrentProfiles { cloned.OrgCurrentProfiles[corpID] = selector } } return &cloned } func snapshotDeletionIdentities(cfg *ProfilesConfig, removed Profile, exact bool) ([]deletionIdentitySnapshot, error) { var profiles []Profile if exact { profiles = []Profile{removed} } else { for _, candidate := range cfg.Profiles { if strings.TrimSpace(candidate.CorpID) == strings.TrimSpace(removed.CorpID) { profiles = append(profiles, candidate) } } } snapshots := make([]deletionIdentitySnapshot, 0, len(profiles)) for _, candidate := range profiles { if strings.TrimSpace(candidate.UserID) == "" { continue } data, err := tokenLoadKeychainIdentity(candidate.CorpID, candidate.UserID) if err != nil { if errors.Is(err, ErrTokenDataNotFound) { snapshots = append(snapshots, deletionIdentitySnapshot{profile: candidate}) continue } // A damaged target slot must remain removable. It cannot be restored // during rollback, but every readable slot in the same transaction // still is. snapshots = append(snapshots, deletionIdentitySnapshot{profile: candidate}) continue } snapshots = append(snapshots, deletionIdentitySnapshot{profile: candidate, token: data}) } return snapshots, nil } func snapshotTokenSlot(load func() (*TokenData, error)) (tokenSlotSnapshot, error) { data, err := load() if err != nil { if errors.Is(err, ErrTokenDataNotFound) { return tokenSlotSnapshot{known: true}, nil } return tokenSlotSnapshot{}, err } return tokenSlotSnapshot{token: data, known: true, exists: data != nil}, nil } func snapshotTokenSlotForDeletion(load func() (*TokenData, error)) tokenSlotSnapshot { snapshot, err := snapshotTokenSlot(load) if err != nil { return tokenSlotSnapshot{} } return snapshot } func snapshotTokenMarker(configDir string) (tokenMarkerSnapshot, error) { data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile)) if err != nil { if os.IsNotExist(err) { return tokenMarkerSnapshot{known: true}, nil } return tokenMarkerSnapshot{}, err } var marker TokenMarker if err := json.Unmarshal(data, &marker); err != nil { return tokenMarkerSnapshot{known: true, exists: true}, nil } return tokenMarkerSnapshot{known: true, exists: true, manual: marker.ManualToken}, nil } func snapshotTokenMarkerForDeletion(configDir string) tokenMarkerSnapshot { snapshot, err := snapshotTokenMarker(configDir) if err != nil { return tokenMarkerSnapshot{} } return snapshot } func restoreProfileDeletion( configDir string, cfg *ProfilesConfig, identities []deletionIdentitySnapshot, corpID string, org tokenSlotSnapshot, legacy tokenSlotSnapshot, marker tokenMarkerSnapshot, ) error { var rollbackErr error for _, snapshot := range identities { if snapshot.token == nil { continue } if err := tokenSaveKeychainForIdentity( snapshot.profile.CorpID, snapshot.profile.UserID, snapshot.token, ); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } if err := tokenSaveProfiles(configDir, cloneProfilesConfig(cfg)); err != nil { rollbackErr = errors.Join(rollbackErr, err) } if org.known { if org.exists { if err := tokenSaveKeychainForCorpID(corpID, org.token); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } else if err := tokenDeleteKeychainForCorpID(corpID); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } if legacy.known { if legacy.exists { if err := tokenSaveKeychain(legacy.token); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } else if err := tokenDeleteKeychain(); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } if marker.known { if err := restoreTokenMarker(configDir, marker); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } return rollbackErr } func snapshotTokenPersistence( configDir string, cfg *ProfilesConfig, corpID, userID string, includeOrganization bool, ) (tokenPersistenceSnapshot, error) { snapshot := tokenPersistenceSnapshot{ profiles: cloneProfilesConfig(cfg), corpID: corpID, userID: userID, } var err error if strings.TrimSpace(userID) != "" { snapshot.identity, err = snapshotTokenSlot(func() (*TokenData, error) { return tokenLoadKeychainIdentity(corpID, userID) }) if err != nil { return tokenPersistenceSnapshot{}, err } } if includeOrganization { snapshot.org, err = snapshotTokenSlot(func() (*TokenData, error) { return tokenLoadKeychainForCorpID(corpID) }) if err != nil { return tokenPersistenceSnapshot{}, err } } snapshot.legacy, err = snapshotTokenSlot(tokenLoadKeychain) if err != nil { return tokenPersistenceSnapshot{}, err } snapshot.marker, err = snapshotTokenMarker(configDir) if err != nil { return tokenPersistenceSnapshot{}, err } return snapshot, nil } func restoreTokenPersistence(configDir string, snapshot tokenPersistenceSnapshot) error { var rollbackErr error if err := tokenSaveProfiles(configDir, cloneProfilesConfig(snapshot.profiles)); err != nil { rollbackErr = errors.Join(rollbackErr, err) } if strings.TrimSpace(snapshot.userID) != "" && snapshot.identity.known { if err := restoreTokenSlot( snapshot.identity, func(data *TokenData) error { return tokenSaveKeychainForIdentity(snapshot.corpID, snapshot.userID, data) }, func() error { return tokenDeleteKeychainIdentity(snapshot.corpID, snapshot.userID) }, ); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } if snapshot.org.known { if err := restoreTokenSlot( snapshot.org, func(data *TokenData) error { return tokenSaveKeychainForCorpID(snapshot.corpID, data) }, func() error { return tokenDeleteKeychainForCorpID(snapshot.corpID) }, ); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } if snapshot.legacy.known { if err := restoreTokenSlot(snapshot.legacy, tokenSaveKeychain, tokenDeleteKeychain); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } if snapshot.marker.known { if err := restoreTokenMarker(configDir, snapshot.marker); err != nil { rollbackErr = errors.Join(rollbackErr, err) } } return rollbackErr } func restoreTokenSlot( snapshot tokenSlotSnapshot, save func(*TokenData) error, remove func() error, ) error { if snapshot.exists { return save(snapshot.token) } return remove() } func restoreTokenMarker(configDir string, marker tokenMarkerSnapshot) error { switch { case !marker.exists: return tokenDeleteMarker(configDir) case marker.manual: return tokenWriteManualMarker(configDir) default: return tokenWriteMarker(configDir) } } // DeleteAllTokenData removes all profile-scoped and legacy token data. func DeleteAllTokenData(configDir string) error { if h := edition.Get(); h.DeleteToken != nil { return withProfilesLock(configDir, func() error { return h.DeleteToken(configDir) }) } return withProfilesLock(configDir, func() error { var firstErr error // Sweep the complete auth-token namespace so orphan identity slots that // are not present in profiles.json cannot survive reset/logout --all. if e := tokenRemoveAuthTokenEntries(keychain.Service); e != nil { firstErr = e } if e := tokenRemove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil { firstErr = e } // Sweep any quarantined corrupt-profiles files so they don't accumulate. if matches, _ := tokenGlob(ProfilesPath(configDir) + ".corrupt-*"); len(matches) > 0 { for _, m := range matches { if e := tokenRemove(m); e != nil && !os.IsNotExist(e) && firstErr == nil { firstErr = e } } } if e := tokenDeleteSecure(configDir); e != nil && firstErr == nil { firstErr = e } if e := tokenDeleteMarker(configDir); e != nil && firstErr == nil { firstErr = e } if firstErr != nil { // Preserve an explicit v2 empty registry so any stale mirror that // could not be removed is never imported on a later read. if e := profilesSave(configDir, &ProfilesConfig{Version: profilesVersion}); e != nil { return fmt.Errorf("%v; save logged-out profile tombstone: %w", firstErr, e) } } return firstErr }) } // RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token. // Uses MCP revoke endpoint when clientID is from MCP, otherwise uses DingTalk logout. // This should be called before deleting local token data. // The function is best-effort: errors are returned but callers may choose to ignore them. func RevokeTokenRemote(ctx context.Context) error { tokenData, err := tokenLoadData(tokenDefaultConfigDir()) if err != nil || tokenData == nil { return nil } // Historical token records may not have Source. Preserve the legacy // process-wide MCP decision only for those records. if strings.TrimSpace(tokenData.Source) == "" && IsClientIDFromMCP() { copy := *tokenData copy.Source = "mcp" tokenData = © } return RevokeTokenRemoteForData(ctx, tokenData) } // RevokeTokenRemoteForData revokes the supplied account token using the // credential source and client ID persisted with that exact identity. func RevokeTokenRemoteForData(ctx context.Context, tokenData *TokenData) error { if tokenData == nil { return nil } clientID := strings.TrimSpace(tokenData.ClientID) if clientID == "" { clientID = ClientID() } if strings.EqualFold(strings.TrimSpace(tokenData.Source), "mcp") { return revokeTokenViaMCP(ctx, tokenData, clientID) } // Direct mode: use DingTalk logout endpoint. logoutURL, err := tokenParseURL(tokenLogoutURL) if err != nil { return fmt.Errorf("parsing logout URL: %w", err) } q := logoutURL.Query() q.Set("client_id", clientID) q.Set("continue", tokenLogoutContinueURL) logoutURL.RawQuery = q.Encode() req, err := tokenNewRequest(ctx, http.MethodGet, logoutURL.String(), nil) if err != nil { return fmt.Errorf("creating logout request: %w", err) } resp, err := tokenLogoutHTTPClient.Do(req) if err != nil { return fmt.Errorf("calling logout endpoint: %w", err) } defer resp.Body.Close() // Accept 200 OK or 302 redirect as success. if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusFound { return fmt.Errorf("logout endpoint returned status %d", resp.StatusCode) } return nil } // revokeTokenViaMCP revokes token via MCP endpoint. func revokeTokenViaMCP(ctx context.Context, tokenData *TokenData, clientID string) error { revokeURL := tokenMCPBaseURL() + MCPRevokeTokenPath body := map[string]string{ "clientId": clientID, "accessToken": tokenData.AccessToken, } bodyBytes, err := tokenJSONMarshal(body) if err != nil { return fmt.Errorf("marshaling revoke request: %w", err) } req, err := tokenNewRequest(ctx, http.MethodPost, revokeURL, bytes.NewReader(bodyBytes)) if err != nil { return fmt.Errorf("creating revoke request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := tokenRevokeHTTPClient.Do(req) if err != nil { return fmt.Errorf("calling revoke endpoint: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("revoke endpoint returned status %d", resp.StatusCode) } return nil }