Files
dingtalk-workspace-cli/internal/auth/app_config.go
2026-07-15 11:41:30 +08:00

276 lines
8.3 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 (
"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-<edition>.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
}