From 426ad50927e4fc983c073b5a1556ed3b8684ae2c Mon Sep 17 00:00:00 2001 From: "muling.cs" Date: Mon, 24 Aug 2026 18:06:05 +0800 Subject: [PATCH 1/3] refresh use legacy global slot --- internal/auth/oauth_provider.go | 126 +++++++ internal/auth/refresh_failure_test.go | 472 ++++++++++++++++++++++++++ 2 files changed, 598 insertions(+) diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 02ad2380..219693ce 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -828,6 +828,12 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) { fallback, fErr := p.refreshFromOrgSlot(ctx, data) if fErr != nil { logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr) + // The organization mirror may be absent for long-lived local logins + // that predate mirror publication. Recover from the legacy global + // slot before giving up. + if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil { + return recovered, nil + } return nil, rErr } if p.logger != nil { @@ -891,6 +897,126 @@ func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenDa return refreshed, nil } +func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) { + var exchangeErr *MCPTokenExchangeError + if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() { + return nil, refreshErr + } + if selected == nil { + return nil, refreshErr + } + slog.Debug("auth.refresh.legacy_recovery.triggered", + "corp_id", strings.TrimSpace(selected.CorpID), + "user_id", strings.TrimSpace(selected.UserID), + "refresh_error_code", exchangeErr.Code, + ) + legacy, loadErr := tokenLoadKeychain() + if loadErr != nil { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr) + return nil, refreshErr + } + if legacy == nil { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy") + return nil, refreshErr + } + if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) { + slog.Debug("auth.refresh.legacy_recovery.failed", + "step", "candidate_mismatch", + "legacy_corp_id", strings.TrimSpace(legacy.CorpID), + "legacy_user_id", strings.TrimSpace(legacy.UserID), + ) + return nil, refreshErr + } + recovered := *legacy + if strings.TrimSpace(recovered.UserID) == "" { + recovered.UserID = strings.TrimSpace(selected.UserID) + } + if strings.TrimSpace(recovered.UserName) == "" { + recovered.UserName = strings.TrimSpace(selected.UserName) + } + if recovered.IsAccessTokenValid() { + if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err) + return nil, refreshErr + } + slog.Debug("auth.refresh.legacy_recovery.success", "via", "valid_access_token") + return &recovered, nil + } + if !recovered.IsRefreshTokenValid() { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired") + return nil, refreshErr + } + if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token") + return nil, refreshErr + } + if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err) + return nil, refreshErr + } + refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered) + if recoverErr != nil { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr) + return nil, refreshErr + } + slog.Debug("auth.refresh.legacy_recovery.success", "via", "refresh") + return refreshed, nil +} + +func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool { + if selected == nil || legacy == nil { + return false + } + selectedCorpID := strings.TrimSpace(selected.CorpID) + legacyCorpID := strings.TrimSpace(legacy.CorpID) + if selectedCorpID == "" || legacyCorpID != selectedCorpID { + return false + } + selectedUserID := strings.TrimSpace(selected.UserID) + legacyUserID := strings.TrimSpace(legacy.UserID) + if selectedUserID == "" { + return legacyUserID == "" + } + if legacyUserID != "" { + return legacyUserID == selectedUserID + } + return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID) +} + +func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool { + if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" { + return false + } + cfg, err := tokenLoadProfiles(configDir) + if err != nil || cfg == nil { + slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err) + return false + } + profiles := profilesForCorpID(cfg, corpID) + if len(profiles) != 1 { + slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", + "reason", "multi_profile", + "corp_id", strings.TrimSpace(corpID), + "profile_count", len(profiles), + ) + return false + } + profile := profiles[0] + if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) { + return true + } + profileUserID := "" + if profile != nil { + profileUserID = strings.TrimSpace(profile.UserID) + } + slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", + "reason", "identity_mismatch", + "selected_user_id", strings.TrimSpace(userID), + "profile_user_id", profileUserID, + ) + return false +} + // ExchangeAuthCode takes an AuthCode and an optional UserID provided by an // external host, exchanges it for tokens, and persists them. func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) { diff --git a/internal/auth/refresh_failure_test.go b/internal/auth/refresh_failure_test.go index 5281270f..9db8bf91 100644 --- a/internal/auth/refresh_failure_test.go +++ b/internal/auth/refresh_failure_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -189,6 +190,150 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR } } +func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039", + UserID: "user-v1039", + UserName: "V1039 User", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{ + Name: "V1039 User", + CorpID: selected.CorpID, + UserID: selected.UserID, + UserName: selected.UserName, + }}}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + var saved *TokenData + testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error { + copy := *data + saved = © + return nil + }) + + recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if err != nil { + t.Fatalf("lockedRefresh() error = %v", err) + } + if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken { + t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy) + } + if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName { + t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName) + } + if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID { + t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered) + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-multi", + UserID: "user-v1039-a", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{ + {Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID}, + {Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"}, + }}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-user-mismatch", + UserID: "user-v1039-selected", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: "user-v1039-other", + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a token owned by a different user") + } +} + func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) { fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true) expired := *fixture.blankToken @@ -253,3 +398,330 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola } } } + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-single-mismatch", + UserID: "user-v1039-selected", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{ + Name: "Other User", + CorpID: selected.CorpID, + UserID: "user-v1039-other", + }}}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-legacy-refresh", + UserID: "user-v1039", + UserName: "V1039 User", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "expired-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + refreshed := &TokenData{ + AccessToken: "refreshed-legacy-access", + RefreshToken: "rotated-legacy-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + refreshCalls := 0 + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + refreshCalls++ + if refreshCalls == 1 { + return nil, rejection + } + return refreshed, nil + }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + + recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if err != nil { + t.Fatalf("lockedRefresh() error = %v", err) + } + if refreshCalls != 2 { + t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls) + } + if recovered.AccessToken != refreshed.AccessToken { + t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken) + } + if recovered.RefreshToken != refreshed.RefreshToken { + t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken) + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-profiles-error", + UserID: "user-v1039", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "shared-rejected-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-same-refresh", + UserID: "user-v1039", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "expired-legacy-global-access", + RefreshToken: selected.RefreshToken, + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) { + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + provider := NewOAuthProvider(t.TempDir(), nil) + + // nil selected must be rejected before any dereference. + if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) { + t.Fatalf("nil selected error = %v, want original rejection", err) + } + + // A keychain load that returns (nil, nil) must be rejected before any dereference. + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil }) + selected := &TokenData{ + CorpID: "corp-v1039-nil-legacy", + UserID: "user-v1039", + Source: "mcp", + } + if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("nil legacy error = %v, want original rejection", err) + } +} + +func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) { + provider := NewOAuthProvider(t.TempDir(), nil) + selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"} + + plainErr := errors.New("plain refresh failure") + if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) { + t.Fatalf("plain error = %v, want original plain failure", err) + } +} + +func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) { + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + selected := &TokenData{ + CorpID: "corp-v1039-recovery-steps", + UserID: "user-v1039", + Source: "mcp", + } + + t.Run("save_failure", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "valid-legacy-access", + RefreshToken: "valid-legacy-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("save failure error = %v, want original rejection", err) + } + }) + + t.Run("refresh_expired", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "expired-legacy-access", + RefreshToken: "expired-legacy-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(-time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("expired refresh error = %v, want original rejection", err) + } + }) + + t.Run("refresh_error", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "expired-legacy-access", + RefreshToken: "valid-legacy-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, errors.New("legacy refresh failed") + }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("legacy refresh error = %v, want original rejection", err) + } + }) + + t.Run("preflight_error", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "expired-legacy-access", + RefreshToken: "valid-legacy-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("preflight error = %v, want original rejection", err) + } + }) +} + +func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) { + configDir := t.TempDir() + selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"} + + if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) { + t.Fatal("nil legacy accepted") + } + if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) { + t.Fatal("different corp accepted") + } + if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) { + t.Fatal("blank selected corp accepted") + } + + blankSelected := &TokenData{CorpID: selected.CorpID} + if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) { + t.Fatal("both blank user IDs should match") + } + if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) { + t.Fatal("blank selected with non-blank legacy accepted") + } + + if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) { + t.Fatal("blank corp accepted by single-profile check") + } + if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selected.CorpID, "") { + t.Fatal("blank user accepted by single-profile check") + } +} From 0bfb1ff1deda79ddd1890f2aa861d32cbb35f7b3 Mon Sep 17 00:00:00 2001 From: "muling.cs" Date: Mon, 24 Aug 2026 18:11:07 +0800 Subject: [PATCH 2/3] chore: add release fragment for legacy global slot recovery --- .changes/legacy-global-slot-recovery.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/legacy-global-slot-recovery.md diff --git a/.changes/legacy-global-slot-recovery.md b/.changes/legacy-global-slot-recovery.md new file mode 100644 index 00000000..daaf9dab --- /dev/null +++ b/.changes/legacy-global-slot-recovery.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations. From cbbc9a9f90f91b43ffb9dd46858861c96414343c Mon Sep 17 00:00:00 2001 From: "muling.cs" Date: Mon, 24 Aug 2026 20:13:26 +0800 Subject: [PATCH 3/3] refresh token legacy_recovery --- internal/auth/oauth_provider.go | 33 ++++++++--------- internal/auth/refresh_failure_test.go | 53 +++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 219693ce..be9ae37b 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -905,22 +905,22 @@ func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, if selected == nil { return nil, refreshErr } - slog.Debug("auth.refresh.legacy_recovery.triggered", + logging.AuthDebug("auth.refresh.legacy_recovery.triggered", "corp_id", strings.TrimSpace(selected.CorpID), "user_id", strings.TrimSpace(selected.UserID), "refresh_error_code", exchangeErr.Code, ) legacy, loadErr := tokenLoadKeychain() if loadErr != nil { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr) + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr) return nil, refreshErr } if legacy == nil { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy") + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy") return nil, refreshErr } if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) { - slog.Debug("auth.refresh.legacy_recovery.failed", + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "candidate_mismatch", "legacy_corp_id", strings.TrimSpace(legacy.CorpID), "legacy_user_id", strings.TrimSpace(legacy.UserID), @@ -936,30 +936,30 @@ func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, } if recovered.IsAccessTokenValid() { if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err) + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err) return nil, refreshErr } - slog.Debug("auth.refresh.legacy_recovery.success", "via", "valid_access_token") + logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token") return &recovered, nil } if !recovered.IsRefreshTokenValid() { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired") + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired") return nil, refreshErr } if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token") + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token") return nil, refreshErr } if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err) + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err) return nil, refreshErr } refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered) if recoverErr != nil { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr) + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr) return nil, refreshErr } - slog.Debug("auth.refresh.legacy_recovery.success", "via", "refresh") + logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh") return refreshed, nil } @@ -974,9 +974,6 @@ func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *Tok } selectedUserID := strings.TrimSpace(selected.UserID) legacyUserID := strings.TrimSpace(legacy.UserID) - if selectedUserID == "" { - return legacyUserID == "" - } if legacyUserID != "" { return legacyUserID == selectedUserID } @@ -984,17 +981,17 @@ func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *Tok } func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool { - if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" { + if strings.TrimSpace(corpID) == "" { return false } cfg, err := tokenLoadProfiles(configDir) if err != nil || cfg == nil { - slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err) + logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err) return false } profiles := profilesForCorpID(cfg, corpID) if len(profiles) != 1 { - slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", + logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "multi_profile", "corp_id", strings.TrimSpace(corpID), "profile_count", len(profiles), @@ -1009,7 +1006,7 @@ func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID strin if profile != nil { profileUserID = strings.TrimSpace(profile.UserID) } - slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", + logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "identity_mismatch", "selected_user_id", strings.TrimSpace(userID), "profile_user_id", profileUserID, diff --git a/internal/auth/refresh_failure_test.go b/internal/auth/refresh_failure_test.go index 9db8bf91..62bc7ab7 100644 --- a/internal/auth/refresh_failure_test.go +++ b/internal/auth/refresh_failure_test.go @@ -293,6 +293,51 @@ func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountC } } +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-blank-selected", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{ + {Name: "Blank A", CorpID: selected.CorpID, UserID: ""}, + {Name: "Blank B", CorpID: selected.CorpID, UserID: ""}, + }}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization") + } +} + func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) { selected := &TokenData{ AccessToken: "expired-identity-access", @@ -711,8 +756,11 @@ func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing } blankSelected := &TokenData{CorpID: selected.CorpID} + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil + }) if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) { - t.Fatal("both blank user IDs should match") + t.Fatal("both blank user IDs should match only through the single-profile guard") } if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) { t.Fatal("blank selected with non-blank legacy accepted") @@ -721,7 +769,4 @@ func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) { t.Fatal("blank corp accepted by single-profile check") } - if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selected.CorpID, "") { - t.Fatal("blank user accepted by single-profile check") - } }