1326 lines
44 KiB
Go
1326 lines
44 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 (
|
|
"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:"-"`
|
|
// RepairOrganizationMirror marks a fallback refresh that consumed the
|
|
// organization mirror's refresh_token. The regular write plan can skip the
|
|
// organization slot under an explicit runtime selector (for example when an
|
|
// unresolved sibling profile still owns it), which would strand a
|
|
// refresh_token the server has already rotated; the marker forces the
|
|
// rotated credential back into that slot.
|
|
RepairOrganizationMirror 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
|
|
RepairOrganizationMirror 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
|
|
// A fallback refresh consumed the organization mirror's refresh_token;
|
|
// the rotated credential must go back into that slot even when the
|
|
// selector-driven plan would skip it (for example an explicit --profile
|
|
// that preserves an unresolved sibling profile's slot).
|
|
plan.RepairOrganizationMirror = data.RepairOrganizationMirror
|
|
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.RepairOrganizationMirror ||
|
|
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,
|
|
"repair_org_mirror", plan.RepairOrganizationMirror,
|
|
"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
|
|
}
|