// 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 ( "encoding/json" "fmt" "log/slog" "os" "path/filepath" "sync" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" configpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) const ( // appConfigFile is the filename for the open-source edition's app // credentials store. Sibling editions get a name-suffixed file via // config.EditionFileName so two dws binaries sharing the same config // directory (~/.dws or DWS_CONFIG_DIR) cannot read/write each other's // credentials. See GetAppConfigPath for the path derivation contract. appConfigBase = "app" appConfigExt = ".json" appConfigFile = appConfigBase + appConfigExt ) // AppConfig represents the application credentials configuration. // This is stored in the edition-specific app config file, with the client // secret securely stored in keychain when present. type AppConfig struct { ClientID string `json:"clientId"` ClientSecret SecretInput `json:"clientSecret"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt,omitempty"` } // Cached app config for performance (avoid repeated file reads). var ( cachedAppConfig *AppConfig cachedAppConfigOnce sync.Once cachedAppConfigMu sync.RWMutex ) var ( appConfigStoreSecret = StoreSecret appConfigMarshalIndent = json.MarshalIndent appConfigAtomicWrite = helpers.AtomicWriteJSON appConfigReadFile = os.ReadFile appConfigRemove = os.Remove appConfigLoad = LoadAppConfig appConfigResolveSecret = ResolveSecret appConfigBeforeResolveLock = func() {} ) // Cached resolved credentials (avoid repeated keychain access). var ( cachedResolvedID string cachedResolvedSecret string cachedResolvedValid bool cachedResolvedMu sync.RWMutex ) // GetAppConfigPath returns the path to the app config file for the // currently-active edition. The filename is partitioned by edition so that // two dws binaries from different editions sharing the same configDir // (typically ~/.dws or DWS_CONFIG_DIR) cannot read or overwrite each // other's credentials. Open-source stays on "app.json" for backwards // compatibility; sibling editions land on "app-.json". func GetAppConfigPath(configDir string) string { return filepath.Join(configDir, configpkg.EditionFileName(edition.Get().Name, appConfigBase, appConfigExt)) } // LoadAppConfig loads the app configuration from disk. // Returns nil, nil if the config file does not exist. func LoadAppConfig(configDir string) (*AppConfig, error) { path := GetAppConfigPath(configDir) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("reading app config: %w", err) } var config AppConfig if err := json.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("parsing app config: %w", err) } return &config, nil } // SaveAppConfig saves the app configuration to disk. // If the client secret is a plain string, it will be stored in keychain // and the config file will contain a reference to it. func SaveAppConfig(configDir string, config *AppConfig) error { // Store plain secret in keychain, convert to reference if config.ClientSecret.IsPlain() && config.ClientID != "" { storedRef, err := appConfigStoreSecret(config.ClientID, config.ClientSecret) if err != nil { return fmt.Errorf("storing client secret: %w", err) } config.ClientSecret = storedRef } // Update timestamps if config.CreatedAt.IsZero() { config.CreatedAt = time.Now() } config.UpdatedAt = time.Now() data, err := appConfigMarshalIndent(config, "", " ") if err != nil { return fmt.Errorf("marshaling app config: %w", err) } path := GetAppConfigPath(configDir) if err := appConfigAtomicWrite(path, append(data, '\n')); err != nil { return fmt.Errorf("writing app config: %w", err) } cleanupLegacySiblingAppConfig(configDir, config) // Update cache cachedAppConfigMu.Lock() cachedAppConfig = config cachedAppConfigMu.Unlock() // Invalidate resolved credentials cache so next access re-resolves cachedResolvedMu.Lock() cachedResolvedValid = false cachedResolvedID = "" cachedResolvedSecret = "" cachedResolvedMu.Unlock() return nil } func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) { if config == nil || config.ClientID == "" || configpkg.IsOpenEdition(edition.Get().Name) { return } legacyPath := filepath.Join(configDir, appConfigFile) data, err := appConfigReadFile(legacyPath) if err != nil { return } var legacy AppConfig if err := json.Unmarshal(data, &legacy); err != nil { return } if legacy.ClientID != config.ClientID { return } if err := appConfigRemove(legacyPath); err != nil && !os.IsNotExist(err) { slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err) } } // DeleteAppConfig removes the app configuration and associated keychain secrets. func DeleteAppConfig(configDir string) error { // Load existing config to clean up keychain existing, _ := LoadAppConfig(configDir) if existing != nil { RemoveSecretStore(existing.ClientSecret) } // Remove config file path := GetAppConfigPath(configDir) if err := appConfigRemove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("removing app config: %w", err) } // Clear cache cachedAppConfigMu.Lock() cachedAppConfig = nil cachedAppConfigMu.Unlock() // Clear resolved credentials cache cachedResolvedMu.Lock() cachedResolvedValid = false cachedResolvedID = "" cachedResolvedSecret = "" cachedResolvedMu.Unlock() return nil } // GetCachedAppConfig returns the cached app configuration. // It loads from disk on first call and caches the result. // Returns nil if no configuration exists or loading fails. func GetCachedAppConfig(configDir string) *AppConfig { cachedAppConfigOnce.Do(func() { cfg, err := LoadAppConfig(configDir) if err == nil && cfg != nil { cachedAppConfigMu.Lock() cachedAppConfig = cfg cachedAppConfigMu.Unlock() } }) cachedAppConfigMu.RLock() defer cachedAppConfigMu.RUnlock() return cachedAppConfig } // ReloadAppConfig forces a reload of the app configuration from disk. // This should be called after SaveAppConfig to ensure the cache is updated. func ReloadAppConfig(configDir string) (*AppConfig, error) { cfg, err := appConfigLoad(configDir) if err != nil { return nil, err } cachedAppConfigMu.Lock() cachedAppConfig = cfg cachedAppConfigMu.Unlock() return cfg, nil } // HasAppConfig returns true if an app configuration file exists. func HasAppConfig(configDir string) bool { path := GetAppConfigPath(configDir) _, err := os.Stat(path) return err == nil } // ResolveAppCredentials resolves the client ID and secret from the app config. // Results are cached to avoid repeated keychain access. // Returns empty strings if the config doesn't exist or resolution fails. func ResolveAppCredentials(configDir string) (clientID, clientSecret string) { // Fast path: check cache first cachedResolvedMu.RLock() if cachedResolvedValid { id, secret := cachedResolvedID, cachedResolvedSecret cachedResolvedMu.RUnlock() return id, secret } cachedResolvedMu.RUnlock() // Slow path: load and cache appConfigBeforeResolveLock() cachedResolvedMu.Lock() defer cachedResolvedMu.Unlock() // Double-check after acquiring write lock if cachedResolvedValid { return cachedResolvedID, cachedResolvedSecret } cfg := GetCachedAppConfig(configDir) if cfg != nil { cachedResolvedID = cfg.ClientID if secret, err := appConfigResolveSecret(cfg.ClientSecret); err == nil { cachedResolvedSecret = secret } } cachedResolvedValid = true return cachedResolvedID, cachedResolvedSecret }