// 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. //go:build darwin package auth import ( "bytes" "context" "encoding/json" "errors" "io" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" ) type preflightRoundTripFunc func(*http.Request) (*http.Response, error) func (f preflightRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func seedUnreadableTokenStorage(t *testing.T, configDir string, data *TokenData) { t.Helper() t.Setenv(keychain.DisableKeychainEnv, "1") if err := SaveTokenData(configDir, data); err != nil { t.Fatalf("SaveTokenData() error = %v", err) } dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek") if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil { t.Fatalf("WriteFile(replacement DEK) error = %v", err) } } func setPreflightTestCredentials(t *testing.T) { t.Helper() SetClientID("preflight-client-id") SetClientSecret("preflight-client-secret") resetClientIDFromMCP() t.Cleanup(func() { SetClientID("") SetClientSecret("") resetClientIDFromMCP() }) } func profileCiphertextPathForTest(corpID string) string { account := strings.ReplaceAll(TokenAccountForCorpID(corpID), ":", "_") return filepath.Join(keychain.StorageDir(keychain.Service), account+".enc") } func TestLoadTokenDataFallsBackToLegacyOnlyWhenCurrentSlotIsMissing(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") configDir := t.TempDir() data := testToken("at_fallback", "corp_fallback", "Fallback Org") if err := SaveTokenData(configDir, data); err != nil { t.Fatalf("SaveTokenData() error = %v", err) } if err := DeleteTokenDataKeychainForCorpID(data.CorpID); err != nil { t.Fatalf("DeleteTokenDataKeychainForCorpID() error = %v", err) } if err := preflightTokenPersistence(configDir); err != nil { t.Fatalf("preflightTokenPersistence() with missing profile slot error = %v", err) } loaded, err := LoadTokenData(configDir) if err != nil { t.Fatalf("LoadTokenData() error = %v", err) } if loaded.AccessToken != data.AccessToken { t.Fatalf("fallback access token = %q, want %q", loaded.AccessToken, data.AccessToken) } } func TestLoadTokenDataUsesIdentitySlotWhenOrganizationMirrorIsUnreadable(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") configDir := t.TempDir() data := testToken("at_unreadable", "corp_unreadable", "Unreadable Org") if err := SaveTokenData(configDir, data); err != nil { t.Fatalf("SaveTokenData() error = %v", err) } if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil { t.Fatalf("WriteFile(profile ciphertext) error = %v", err) } loaded, err := LoadTokenData(configDir) if err != nil { t.Fatalf("LoadTokenData() error = %v", err) } if loaded == nil || loaded.AccessToken != data.AccessToken || loaded.UserID != data.UserID { t.Fatalf("LoadTokenData() = %#v, want identity token %#v", loaded, data) } } func TestPreflightTokenPersistenceAllowsEmptyStorageWithoutCreatingDEK(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") configDir := t.TempDir() if err := preflightTokenPersistence(configDir); err != nil { t.Fatalf("preflightTokenPersistence() error = %v", err) } dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek") if _, err := os.Stat(dekPath); !os.IsNotExist(err) { t.Fatalf("preflight created a DEK at %q; stat error = %v", dekPath, err) } } func TestPreflightTokenPersistenceRejectsUnreadableProfileSlot(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") configDir := t.TempDir() data := testToken("at_preflight", "corp_preflight", "Preflight Org") if err := SaveTokenData(configDir, data); err != nil { t.Fatalf("SaveTokenData() error = %v", err) } if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil { t.Fatalf("WriteFile(profile ciphertext) error = %v", err) } err := preflightTokenPersistence(configDir) if err == nil || !strings.Contains(err.Error(), "profile token slot") { t.Fatalf("preflightTokenPersistence() error = %v, want unreadable profile slot", err) } if !strings.Contains(err.Error(), "dws auth logout --profile \""+data.CorpID+"\"") { t.Fatalf("preflightTokenPersistence() error = %v, want per-profile recovery hint", err) } } func TestExactOrgCurrentRefreshRejectsUnreadableOrgMirror(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") configDir := t.TempDir() data := testToken("at_exact_refresh", "corp_exact", "Exact Org") data.UserID = "user_exact" if err := SaveTokenData(configDir, data); err != nil { t.Fatalf("SaveTokenData() error = %v", err) } if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil { t.Fatalf("WriteFile(profile ciphertext) error = %v", err) } SetRuntimeProfile("corp_exact:user_exact") defer SetRuntimeProfile("") if err := preflightTokenRefreshPersistence(configDir, data); err == nil || !strings.Contains(err.Error(), "profile token slot") { t.Fatalf("preflightTokenRefreshPersistence(exact current) error = %v, want unreadable org mirror", err) } } func TestExactNonOrgCurrentRefreshIgnoresUnreadableOrgMirror(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") configDir := t.TempDir() first := testToken("at_first", "corp_exact", "Exact Org") first.UserID = "user_first" second := testToken("at_second", "corp_exact", "Exact Org") second.UserID = "user_second" if err := SaveTokenData(configDir, first); err != nil { t.Fatalf("SaveTokenData(first) error = %v", err) } if err := SaveTokenData(configDir, second); err != nil { t.Fatalf("SaveTokenData(second) error = %v", err) } if err := os.WriteFile(profileCiphertextPathForTest(first.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil { t.Fatalf("WriteFile(profile ciphertext) error = %v", err) } SetRuntimeProfile("corp_exact:user_first") defer SetRuntimeProfile("") if err := preflightTokenRefreshPersistence(configDir, first); err != nil { t.Fatalf("preflightTokenRefreshPersistence(exact non-current) error = %v", err) } updated := *first updated.AccessToken = "at_first_refreshed" updated.RefreshToken = "rt_first_refreshed" if err := SaveTokenData(configDir, &updated); err != nil { t.Fatalf("SaveTokenData(exact non-current refresh) error = %v", err) } loaded, err := LoadTokenDataForProfile(configDir, "corp_exact:user_first") if err != nil { t.Fatalf("LoadTokenDataForProfile(refreshed) error = %v", err) } if loaded.AccessToken != updated.AccessToken || loaded.RefreshToken != updated.RefreshToken { t.Fatalf("refreshed exact token = %#v, want %#v", loaded, updated) } } func TestCrossPlatformCoverageExactRefreshAndSwitchIgnoreUnreadableReservedBlankOrgSlot(t *testing.T) { t.Setenv(keychain.DisableKeychainEnv, "1") fixture := seedLegacyBlankAndExactIdentitySlots(t) if err := os.WriteFile(profileCiphertextPathForTest(fixture.corpID), []byte("corrupt reserved blank slot"), 0o600); err != nil { t.Fatalf("WriteFile(reserved blank ciphertext) error = %v", err) } SetRuntimeProfile("") if err := preflightTokenRefreshPersistence(fixture.configDir, fixture.beta); err != nil { t.Fatalf("preflightTokenRefreshPersistence(current exact with reserved blank) error = %v", err) } refreshed := *fixture.beta refreshed.AccessToken = "at_identity_beta_refreshed" if err := SaveTokenData(fixture.configDir, &refreshed); err != nil { t.Fatalf("SaveTokenData(current exact with reserved blank) error = %v", err) } if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" { t.Fatalf("reserved blank slot changed during exact refresh: %q, %v", raw, err) } if selected, err := SetCurrentProfile(fixture.configDir, profileSelector(fixture.alpha.CorpID, fixture.alpha.UserID)); err != nil || selected.UserID != fixture.alpha.UserID { t.Fatalf("SetCurrentProfile(exact with reserved blank) = %#v, %v", selected, err) } if selected, err := UsePreviousProfile(fixture.configDir); err != nil || selected.UserID != fixture.beta.UserID { t.Fatalf("UsePreviousProfile(exact with reserved blank) = %#v, %v", selected, err) } if raw, err := os.ReadFile(profileCiphertextPathForTest(fixture.corpID)); err != nil || string(raw) != "corrupt reserved blank slot" { t.Fatalf("reserved blank slot changed during exact switches: %q, %v", raw, err) } blankRefresh := *fixture.blank blankRefresh.LegacyOrgScopedProfile = fixture.blankName SetRuntimeProfile(fixture.blankName) if err := preflightTokenRefreshPersistence(fixture.configDir, &blankRefresh); err == nil || !strings.Contains(err.Error(), "profile token slot") { t.Fatalf("blank refresh preflight error = %v, want unreadable reserved slot", err) } } func TestFullTokenPersistenceInventoryDetectsOrphanProfileCiphertext(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") configDir := t.TempDir() data := testToken("at_orphan", "corp_orphan", "Orphan Org") // Simulate interruption after the profile ciphertext rename but before // profiles.json is updated by saveTokenDataLocked. if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil { t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) } if _, err := os.Stat(filepath.Join(configDir, profilesJSONFile)); !os.IsNotExist(err) { t.Fatalf("profiles.json stat error = %v, want missing metadata", err) } dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek") if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x6f}, 32), 0o600); err != nil { t.Fatalf("WriteFile(replacement DEK) error = %v", err) } err := preflightTokenPersistence(configDir) if err == nil || !strings.Contains(err.Error(), "auth token ciphertext inventory") { t.Fatalf("preflightTokenPersistence() error = %v, want orphan ciphertext inventory error", err) } if !keychain.IsCiphertextKeyMismatch(err) { t.Fatalf("preflightTokenPersistence() error = %v, want ciphertext key mismatch in error chain", err) } } func TestPortableAuthExportRejectsCiphertextFromAnotherDEK(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") configDir := t.TempDir() data := testToken("at_portable", "", "") if err := SaveTokenData(configDir, data); err != nil { t.Fatalf("SaveTokenData() error = %v", err) } if !PortableAuthSourceReady() { t.Fatal("PortableAuthSourceReady() = false before replacing DEK") } dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek") if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil { t.Fatalf("WriteFile(replacement DEK) error = %v", err) } if PortableAuthSourceReady() { t.Fatal("PortableAuthSourceReady() = true for ciphertext from another DEK") } var bundle bytes.Buffer if err := ExportPortableAuthBundle(configDir, &bundle); err == nil { t.Fatal("ExportPortableAuthBundle() error = nil for ciphertext from another DEK") } if bundle.Len() != 0 { t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len()) } } func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") configDir := t.TempDir() dataA := testToken("at_a", "corp_a", "A Org") dataB := testToken("at_b", "corp_b", "B Org") if err := SaveTokenData(configDir, dataA); err != nil { t.Fatalf("SaveTokenData(A) error = %v", err) } if err := SaveTokenData(configDir, dataB); err != nil { t.Fatalf("SaveTokenData(B) error = %v", err) } if err := os.WriteFile(profileCiphertextPathForTest(dataA.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil { t.Fatalf("WriteFile(A profile ciphertext) error = %v", err) } if err := preflightTokenRefreshPersistence(configDir, dataB); err != nil { t.Fatalf("preflightTokenRefreshPersistence(B) error = %v", err) } loaded, err := NewOAuthProvider(configDir, nil).Login(context.Background(), false) if err != nil { t.Fatalf("Login() with valid B and unreadable A error = %v", err) } if loaded.AccessToken != dataB.AccessToken { t.Fatalf("Login() access token = %q, want %q", loaded.AccessToken, dataB.AccessToken) } } func TestOAuthLoginUnreadableGlobalFailsClosedBeforeAuthorizationStart(t *testing.T) { setPreflightTestCredentials(t) for _, force := range []bool{false, true} { t.Run("force="+map[bool]string{false: "false", true: "true"}[force], func(t *testing.T) { cleanupKeychain(t) configDir := t.TempDir() seedUnreadableTokenStorage(t, configDir, testToken("at_login", "corp_login", "Login Org")) listenErr := errors.New("authorization listener reached") var calls atomic.Int32 oldListen := oauthListen oauthListen = func(string, string) (net.Listener, error) { calls.Add(1) return nil, listenErr } t.Cleanup(func() { oauthListen = oldListen }) provider := NewOAuthProvider(configDir, nil) provider.NoBrowser = true _, err := provider.Login(context.Background(), force) if err == nil || !strings.Contains(err.Error(), "refusing to overwrite") { t.Fatalf("Login(force=%v) error = %v, want unreadable-global protection", force, err) } if errors.Is(err, listenErr) { t.Fatalf("Login(force=%v) reached authorization listener: %v", force, err) } if got := calls.Load(); got != 0 { t.Fatalf("Login(force=%v) listener calls = %d, want 0", force, got) } }) } } func TestExchangeAuthCodeRejectsUnreadableGlobalBeforeHTTP(t *testing.T) { cleanupKeychain(t) setPreflightTestCredentials(t) configDir := t.TempDir() existing := testToken("at_exchange", "corp_exchange", "Exchange Org") seedUnreadableTokenStorage(t, configDir, existing) var calls atomic.Int32 var saveCalls atomic.Int32 oldSave := oauthSaveToken oauthSaveToken = func(string, *TokenData) error { saveCalls.Add(1) return nil } t.Cleanup(func() { oauthSaveToken = oldSave }) provider := NewOAuthProvider(configDir, nil) provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) { calls.Add(1) return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader( `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_exchange"}`, )), }, nil })} _, err := provider.ExchangeAuthCode(context.Background(), "auth-code", existing.UserID) if err == nil || !strings.Contains(err.Error(), "legacy token slot") { t.Fatalf("ExchangeAuthCode() error = %v, want target token persistence error", err) } if !keychain.IsCiphertextKeyMismatch(err) { t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err) } if got := calls.Load(); got != 0 { t.Fatalf("HTTP calls = %d, want 0", got) } if got := saveCalls.Load(); got != 0 { t.Fatalf("SaveTokenData calls = %d, want 0", got) } } func TestDeviceFlowLoginRejectsUnreadableGlobalBeforeDeviceCodeRequest(t *testing.T) { cleanupKeychain(t) setPreflightTestCredentials(t) configDir := t.TempDir() seedUnreadableTokenStorage(t, configDir, testToken("at_device", "corp_device", "Device Org")) var calls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { calls.Add(1) http.Error(w, "unexpected device code request", http.StatusInternalServerError) })) defer server.Close() provider := NewDeviceFlowProvider(configDir, nil) provider.Output = io.Discard provider.SetBaseURL(server.URL) _, err := provider.Login(context.Background()) if err == nil || !strings.Contains(err.Error(), "legacy token slot") { t.Fatalf("DeviceFlowProvider.Login() error = %v, want unreadable-global protection", err) } if got := calls.Load(); got != 0 { t.Fatalf("device code requests = %d, want 0", got) } } func TestLockedRefreshPreflightsLegacyMirrorBeforeHTTP(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") setPreflightTestCredentials(t) configDir := t.TempDir() data := testToken("at_refresh", "corp_refresh", "Refresh Org") data.ExpiresAt = time.Now().Add(-time.Hour) if err := SaveTokenData(configDir, data); err != nil { t.Fatalf("SaveTokenData() error = %v", err) } legacyPath := filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc") if err := os.WriteFile(legacyPath, []byte("corrupt legacy ciphertext"), 0o600); err != nil { t.Fatalf("WriteFile(legacy ciphertext) error = %v", err) } var calls atomic.Int32 provider := NewOAuthProvider(configDir, nil) provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) { calls.Add(1) return nil, errors.New("unexpected refresh request") })} _, err := provider.lockedRefresh(context.Background()) if err == nil || !strings.Contains(err.Error(), "legacy token slot") { t.Fatalf("lockedRefresh() error = %v, want token persistence preflight error", err) } if got := calls.Load(); got != 0 { t.Fatalf("refresh HTTP calls = %d, want 0", got) } } func TestLockedRefreshRejectsFutureProfilesVersionBeforeHTTP(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") setPreflightTestCredentials(t) configDir := t.TempDir() data := testToken("at_future_refresh", "corp_future", "Future Org") data.ExpiresAt = time.Now().Add(-time.Hour) data.RefreshExpAt = time.Now().Add(time.Hour) if err := SaveTokenData(configDir, data); err != nil { t.Fatalf("SaveTokenData() error = %v", err) } cfg, err := LoadProfiles(configDir) if err != nil { t.Fatalf("LoadProfiles() error = %v", err) } cfg.Version = profilesMaxVersion + 1 raw, err := json.Marshal(cfg) if err != nil { t.Fatalf("json.Marshal() error = %v", err) } if err := os.WriteFile(ProfilesPath(configDir), raw, 0o600); err != nil { t.Fatalf("write future profiles: %v", err) } var calls atomic.Int32 provider := NewOAuthProvider(configDir, nil) provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) { calls.Add(1) return nil, errors.New("unexpected refresh request") })} _, err = provider.Login(context.Background(), false) if err == nil || !strings.Contains(err.Error(), "newer than supported") { t.Fatalf("Login() error = %v, want future profiles rejection", err) } if got := calls.Load(); got != 0 { t.Fatalf("refresh HTTP calls = %d, want 0", got) } } func TestExchangeAuthCodeAllowsFirstLogin(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") setPreflightTestCredentials(t) configDir := t.TempDir() var calls atomic.Int32 provider := NewOAuthProvider(configDir, nil) provider.Output = io.Discard provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) { calls.Add(1) return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader( `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_new"}`, )), }, nil })} data, err := provider.ExchangeAuthCode(context.Background(), "new-code", "user-new") if err != nil { t.Fatalf("ExchangeAuthCode() error = %v", err) } if data.AccessToken != "new-access" || data.UserID != "user-new" { t.Fatalf("ExchangeAuthCode() data = %#v", data) } if got := calls.Load(); got != 1 { t.Fatalf("HTTP calls = %d, want 1", got) } } func TestExchangeAuthCodeExplicitUIDSkipsIdentityOverride(t *testing.T) { cleanupKeychain(t) t.Setenv(keychain.DisableKeychainEnv, "1") setPreflightTestCredentials(t) configDir := t.TempDir() var identityCalls atomic.Int32 provider := NewOAuthProvider(configDir, nil) provider.IdentityEnricher = func(context.Context, *TokenData) error { identityCalls.Add(1) return errors.New("identity lookup should not run for explicit uid") } provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader( `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_new"}`, )), }, nil })} data, err := provider.ExchangeAuthCode(context.Background(), "new-code", "explicit-user") if err != nil { t.Fatalf("ExchangeAuthCode() error = %v", err) } if data.UserID != "explicit-user" { t.Fatalf("ExchangeAuthCode() userId = %q, want explicit-user", data.UserID) } if got := identityCalls.Load(); got != 0 { t.Fatalf("IdentityEnricher calls = %d, want 0", got) } }