diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 87dadbae..0621ec02 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -18,6 +18,8 @@ import ( "path/filepath" "strings" "sync" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) const ( @@ -110,7 +112,7 @@ func SetClientIDFromMCP(id string) { func IsClientIDFromMCP() bool { clientMu.RLock() defer clientMu.RUnlock() - return clientIDFromMCP + return clientIDFromMCP || edition.Get().AuthClientFromMCP } // GetUserAccessTokenURL returns the appropriate token exchange URL. @@ -189,6 +191,9 @@ func ClientID() string { if override != "" { return override } + if id := edition.Get().AuthClientID; id != "" { + return id + } // Try loading from persisted app config if id, _ := ResolveAppCredentials(getDefaultConfigDir()); id != "" { return id diff --git a/internal/auth/token.go b/internal/auth/token.go index 4b7f9472..7dbcf38a 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -20,7 +20,11 @@ import ( "fmt" "net/http" "net/url" + "os" + "path/filepath" "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) // TokenData holds the OAuth token set persisted to disk. @@ -61,10 +65,46 @@ 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"` +} + +// 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 { + marker := TokenMarker{UpdatedAt: time.Now().Format(time.RFC3339)} + data, _ := json.MarshalIndent(marker, "", " ") + if err := os.MkdirAll(configDir, 0o700); err != nil { + return err + } + tmp := filepath.Join(configDir, tokenJSONFile+".tmp") + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, filepath.Join(configDir, tokenJSONFile)) +} + +// DeleteTokenMarker removes the token.json marker file. +func DeleteTokenMarker(configDir string) error { + return os.Remove(filepath.Join(configDir, tokenJSONFile)) +} + // SaveTokenData saves TokenData to the platform keychain. -// Uses the new keychain-based storage with random master key for better security. +// In embedded mode, it also writes a token.json marker file so the host +// application can detect authentication state without accessing the keychain. func SaveTokenData(configDir string, data *TokenData) error { - return SaveTokenDataKeychain(data) + if err := SaveTokenDataKeychain(data); err != nil { + return err + } + if edition.Get().IsEmbedded { + _ = WriteTokenMarker(configDir) + } + return nil } // LoadTokenData reads TokenData from the platform keychain. @@ -91,14 +131,13 @@ func LoadTokenData(configDir string) (*TokenData, error) { } // DeleteTokenData removes token data from both keychain and legacy storage. +// In embedded mode, it also removes the token.json marker file. func DeleteTokenData(configDir string) error { - // Delete from keychain keychainErr := DeleteTokenDataKeychain() - - // Also clean up any legacy .data file legacyErr := DeleteSecureData(configDir) - - // Return keychain error if any, otherwise legacy error + if edition.Get().IsEmbedded { + _ = DeleteTokenMarker(configDir) + } if keychainErr != nil { return keychainErr } diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go index 24514aa5..bef0a43b 100644 --- a/pkg/edition/edition.go +++ b/pkg/edition/edition.go @@ -77,6 +77,10 @@ type Hooks struct { OnAuthError func(configDir string, err error) error TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error) + // --- auth credentials (overlay-only) --- + AuthClientID string // non-empty overrides DefaultClientID + AuthClientFromMCP bool // true routes OAuth through MCP endpoints + // --- product & endpoint --- StaticServers func() []ServerInfo // non-nil → skip Market discovery VisibleProducts func() []string // non-nil → override help visibility