From 75b873d231a6b4f9da142342ff2bb3bc1aed9448 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 31 Mar 2026 05:47:53 +0000 Subject: [PATCH 1/2] chore: update coverage badge [skip ci] --- .github/badges/coverage.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index 47441d4d..c742bb27 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -1 +1 @@ -coverage: 52.3%coverage52.3% \ No newline at end of file +coverage: 52.8%coverage52.8% \ No newline at end of file From 773804ee804c3059eac8970716dc42c9d2be4753 Mon Sep 17 00:00:00 2001 From: fantiu Date: Wed, 1 Apr 2026 00:47:49 +0800 Subject: [PATCH 2/2] feat(auth): enhance device flow with CLI auth check and admin guidance --- internal/app/legacy.go | 42 ++- internal/app/root.go | 15 + internal/app/runner.go | 88 ++++-- internal/app/skill_command_test.go | 4 +- internal/app/version.go | 2 +- internal/auth/auth_extra_test.go | 15 +- internal/auth/device_flow.go | 54 ++++ internal/auth/device_flow_test.go | 4 + internal/auth/endpoints.go | 88 +++++- internal/auth/oauth_helpers.go | 429 +++++++++++++++++++++++++++++ internal/auth/oauth_provider.go | 270 +++++++++++++++++- internal/auth/token.go | 52 +++- internal/errors/errors.go | 14 + test/cli/root_command_test.go | 9 +- 14 files changed, 1040 insertions(+), 46 deletions(-) diff --git a/internal/app/legacy.go b/internal/app/legacy.go index 25fe605a..7b77ce82 100644 --- a/internal/app/legacy.go +++ b/internal/app/legacy.go @@ -16,6 +16,7 @@ package app import ( "context" "encoding/json" + "fmt" "log/slog" "net" "net/http" @@ -59,6 +60,12 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob // Tests may override discoveryBaseURLOverride to redirect to a local server; // in that case the registry cache is always bypassed. func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command { + totalStart := time.Now() + defer func() { + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] loadDynamicCommands total: %v\n", time.Since(totalStart)) + } + }() store := cacheStoreFromEnv() partition := config.DefaultPartition @@ -70,13 +77,20 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C useCache := strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) == "" // --- Cache-first server registry --- + cacheLoadStart := time.Now() snapshot, freshness, cacheErr := store.LoadRegistry(partition) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] cache load: %v (err=%v)\n", time.Since(cacheLoadStart), cacheErr) + } + var servers []market.ServerDescriptor now := store.Now().UTC() usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0 if usingCachedRegistry { - slog.Debug("loadDynamicCommands: using cached registry", "servers", len(snapshot.Servers), "freshness", freshness) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] using cached registry: servers=%d, freshness=%s\n", len(snapshot.Servers), freshness) + } servers = snapshot.Servers // Only trigger async revalidation in production (no URL override). // Tests set discoveryBaseURLOverride and control cache expiry directly, @@ -92,9 +106,15 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C if discoveryBaseURLOverride != "" { baseURL = discoveryBaseURLOverride } - slog.Debug("loadDynamicCommands: fetching servers from market API", "base_url", baseURL) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] fetching from market API: %s\n", baseURL) + } + fetchStart := time.Now() client := market.NewClient(baseURL, ipv4OnlyHTTPClient()) resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] market API fetch: %v (err=%v)\n", time.Since(fetchStart), fetchErr) + } if fetchErr != nil { slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr) // Degrade to stale cache if available (production only). @@ -106,12 +126,18 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C } } else { servers = market.NormalizeServers(resp, "market") - slog.Debug("loadDynamicCommands: normalized servers", "count", len(servers)) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] normalized servers: %d\n", len(servers)) + } // Persist fresh data (only in non-test mode). if useCache { + saveStart := time.Now() if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil { slog.Debug("loadDynamicCommands: failed to save registry cache", "error", saveErr) } + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] cache save: %v\n", time.Since(saveStart)) + } } } } @@ -122,9 +148,17 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C // Inject dynamic server data for endpoint resolution SetDynamicServers(servers) + detailStart := time.Now() detailsByID := loadCachedDetailsFast(store, servers) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] load details: %v\n", time.Since(detailStart)) + } + + buildStart := time.Now() cmds := compat.BuildDynamicCommands(servers, runner, detailsByID) - slog.Debug("loadDynamicCommands: built dynamic commands", "commands", len(cmds)) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] build commands: %v (count=%d)\n", time.Since(buildStart), len(cmds)) + } return cmds } diff --git a/internal/app/root.go b/internal/app/root.go index c6efb026..a35774a1 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -51,19 +51,34 @@ const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID=" // Execute runs the root command and returns the process exit code. func Execute() int { + totalStart := time.Now() + defer func() { + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] Execute total: %v\n", time.Since(totalStart)) + } + }() + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() + initStart := time.Now() recovery.ResetRuntimeState() engine := newPipelineEngine() root := NewRootCommandWithEngine(ctx, engine) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] command init: %v\n", time.Since(initStart)) + } // Run PreParse handlers on raw argv before Cobra parses flags. // This corrects model-generated errors like --userId → --user-id // and --limit100 → --limit 100. pipeline.RunPreParse(root, engine) + execStart := time.Now() executed, err := root.ExecuteC() + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] cobra ExecuteC: %v\n", time.Since(execStart)) + } if err != nil { if executed == nil { executed = root diff --git a/internal/app/runner.go b/internal/app/runner.go index 25ce2192..3b017cb9 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -22,6 +22,7 @@ import ( "net/http" "os" "strings" + "sync" "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" @@ -74,6 +75,13 @@ type runtimeRunner struct { } func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) { + totalStart := time.Now() + defer func() { + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] runtimeRunner.Run total: %v\n", time.Since(totalStart)) + } + }() + if r.loader == nil || r.transport == nil { return r.fallback.Run(ctx, invocation) } @@ -117,7 +125,11 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) } func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) { + authStart := time.Now() tc := r.transport.WithAuth(r.resolveAuthToken(ctx), resolveIdentityHeaders()) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", time.Since(authStart)) + } if invocation.DryRun { return executor.Result{ @@ -148,7 +160,11 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, }, nil } + callStart := time.Now() callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params) + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", time.Since(callStart)) + } if err != nil { captureRuntimeFailure(invocation, err, err) return executor.Result{}, err @@ -207,25 +223,59 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string { if token := strings.TrimSpace(explicitToken); token != "" { return token } - configDir := defaultConfigDir() - provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil))) - configureOAuthProviderCompatibility(provider, configDir) - token, tokenErr := provider.GetAccessToken(ctx) - if tokenErr == nil && strings.TrimSpace(token) != "" { - return strings.TrimSpace(token) - } - // If the error is a decryption failure (corrupted data), surface - // it immediately instead of falling back to empty token. - if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) { - slog.Error(tokenErr.Error()) - return "" - } - manager := authpkg.NewManager(configDir, nil) - configureLegacyAuthManagerCompatibility(manager) - if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" { - return strings.TrimSpace(token) - } - return "" + // Use cached token to avoid repeated Keychain access (~70ms per call) + return getCachedRuntimeToken(ctx) +} + +// Cached token state for process lifetime +var ( + cachedRuntimeToken string + cachedRuntimeTokenOnce sync.Once + cachedRuntimeTokenError error +) + +// getCachedRuntimeToken returns a cached access token, loading it only once per process. +// This avoids repeated Keychain access which takes ~70ms each time. +func getCachedRuntimeToken(ctx context.Context) string { + cachedRuntimeTokenOnce.Do(func() { + loadStart := time.Now() + defer func() { + if os.Getenv("DWS_PERF_DEBUG") != "" { + _, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", time.Since(loadStart)) + } + }() + + configDir := defaultConfigDir() + provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil))) + configureOAuthProviderCompatibility(provider, configDir) + token, tokenErr := provider.GetAccessToken(ctx) + if tokenErr == nil && strings.TrimSpace(token) != "" { + cachedRuntimeToken = strings.TrimSpace(token) + return + } + // If the error is a decryption failure (corrupted data), record it + if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) { + slog.Error(tokenErr.Error()) + cachedRuntimeTokenError = tokenErr + return + } + // Try legacy manager as fallback + manager := authpkg.NewManager(configDir, nil) + configureLegacyAuthManagerCompatibility(manager) + if token, _, err := manager.GetToken(); err == nil && strings.TrimSpace(token) != "" { + cachedRuntimeToken = strings.TrimSpace(token) + return + } + }) + return cachedRuntimeToken +} + +// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access. +// This should be called after login/logout operations. +func ResetRuntimeTokenCache() { + cachedRuntimeTokenOnce = sync.Once{} + cachedRuntimeToken = "" + cachedRuntimeTokenError = nil } func newRuntimeContentScanner() safety.Scanner { diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index f737d4a1..28be8645 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -421,7 +421,9 @@ func TestSkillAddRequiresAuth(t *testing.T) { if err == nil { t.Error("Execute() should have failed without auth") } - if !strings.Contains(err.Error(), "not logged in") && !strings.Contains(err.Error(), "token") { + // Check for authentication-related error (English or Chinese) + errStr := err.Error() + if !strings.Contains(errStr, "not logged in") && !strings.Contains(errStr, "token") && !strings.Contains(errStr, "未登录") && !strings.Contains(errStr, "auth") { t.Errorf("error should mention authentication, got: %v", err) } } diff --git a/internal/app/version.go b/internal/app/version.go index 2942c201..04410556 100644 --- a/internal/app/version.go +++ b/internal/app/version.go @@ -13,7 +13,7 @@ package app -var version = "v1.0.2" +var version = "v1.0.6" // Version returns the current CLI version string, including build metadata // when injected via ldflags (buildTime, gitCommit). diff --git a/internal/auth/auth_extra_test.go b/internal/auth/auth_extra_test.go index 0f8fb940..2eb5b682 100644 --- a/internal/auth/auth_extra_test.go +++ b/internal/auth/auth_extra_test.go @@ -26,6 +26,7 @@ func TestClientID_RuntimeOverride(t *testing.T) { func TestClientID_EnvFallback(t *testing.T) { SetClientID("") + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_ID", "env-id") if got := ClientID(); got != "env-id" { t.Fatalf("expected env-id, got %s", got) @@ -34,9 +35,17 @@ func TestClientID_EnvFallback(t *testing.T) { func TestClientID_Default(t *testing.T) { SetClientID("") + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_ID", "") - if got := ClientID(); got != DefaultClientID { - t.Fatalf("expected default, got %s", got) + // When DefaultClientID is a placeholder (starts with "<"), ClientID() returns empty string + if strings.HasPrefix(DefaultClientID, "<") { + if got := ClientID(); got != "" { + t.Fatalf("expected empty string for placeholder, got %s", got) + } + } else { + if got := ClientID(); got != DefaultClientID { + t.Fatalf("expected default, got %s", got) + } } } @@ -51,6 +60,7 @@ func TestClientSecret_RuntimeOverride(t *testing.T) { func TestClientSecret_EnvFallback(t *testing.T) { SetClientSecret("") + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_SECRET", "env-secret") if got := ClientSecret(); got != "env-secret" { t.Fatalf("expected env-secret, got %s", got) @@ -59,6 +69,7 @@ func TestClientSecret_EnvFallback(t *testing.T) { func TestClientSecret_Default(t *testing.T) { SetClientSecret("") + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) // Use temp dir to avoid reading persisted config t.Setenv("DWS_CLIENT_SECRET", "") if got := ClientSecret(); got != DefaultClientSecret { t.Fatalf("expected default, got %s", got) diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 94f8cc9c..21b44a59 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -96,6 +96,23 @@ type serviceResult struct { } func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) { + // Ensure we have a valid client ID (fetch from MCP if not available) + if p.clientID == "" { + if p.logger != nil { + p.logger.Debug("client ID not configured, fetching from MCP server") + } + mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) + if mcpErr != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) + } + p.clientID = mcpClientID + // Mark that clientID is from MCP + SetClientIDFromMCP(mcpClientID) + if p.logger != nil { + p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + } + } + const maxAttempts = 3 for attempt := 1; attempt <= maxAttempts; attempt++ { tokenData, err := p.loginOnce(ctx, attempt) @@ -150,6 +167,43 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err) } + // Check if CLI auth is enabled for this organization + dfPrintStep(p.output(), 4, i18n.T("检查组织 CLI 授权状态..."), 0) + authStatus, authErr := oauthProvider.CheckCLIAuthEnabled(ctx, tokenData.AccessToken) + if authErr != nil { + if p.logger != nil { + p.logger.Warn("failed to check CLI auth status", "error", authErr) + } + // Continue anyway - fail open for better UX + } else if authStatus.Success && !authStatus.Result.CLIAuthEnabled { + // CLI auth is disabled - show detailed error with admin info + _, _ = fmt.Fprintln(p.output(), "") + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织尚未开启 CLI 数据访问权限"))) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。")) + _, _ = fmt.Fprintln(p.output(), "") + + // Try to get super admin list + admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken) + if adminErr == nil && admins.Success && len(admins.Result) > 0 { + // Show up to 3 admins + maxAdmins := 3 + if len(admins.Result) < maxAdmins { + maxAdmins = len(admins.Result) + } + var adminNames []string + for i := 0; i < maxAdmins; i++ { + adminNames = append(adminNames, admins.Result[i].Name) + } + _, _ = fmt.Fprintf(p.output(), " %s%s\n", i18n.T("组织主管理员:"), strings.Join(adminNames, "、")) + } + + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织主管理员开启后重新登录。")) + _, _ = fmt.Fprintln(p.output(), "") + _, _ = fmt.Fprintln(p.output(), i18n.T(" 管理员操作入口:https://open-dev.dingtalk.com/fe/old#/developerSettings")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("该组织尚未开启 CLI 数据访问权限,请联系管理员开启")) + } + // Save token data with associated client ID for refresh tokenData.ClientID = p.clientID if err := SaveTokenData(p.configDir, tokenData); err != nil { diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 446cec57..d56201f4 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -46,6 +46,10 @@ func writeServiceResult(w http.ResponseWriter, success bool, result any, errCode func TestRequestDeviceCodeSuccess(t *testing.T) { t.Parallel() + // Set a test client ID + SetClientID("test-client-id") + t.Cleanup(func() { SetClientID("") }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Fatalf("method = %s, want POST", r.Method) diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index af5aa791..797017a7 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -16,6 +16,7 @@ package auth import ( "os" "path/filepath" + "strings" "sync" ) @@ -59,16 +60,86 @@ const ( LogoutURL = "https://login.dingtalk.com/oauth2/logout" LogoutContinueURL = "https://login.dingtalk.com" + + // MCP API endpoints for CLI authorization management. + DefaultMCPBaseURL = "https://mcp.dingtalk.com" + CLIAuthEnabledPath = "/cli/cliAuthEnabled" + SuperAdminPath = "/cli/superAdmin" + SendCliAuthApplyPath = "/cli/sendCliAuthApply" + ClientIDPath = "/cli/clientId" + + // MCP OAuth endpoints (used when clientId is fetched from MCP). + MCPOAuthTokenPath = "/oauth2/getToken" + MCPRefreshTokenPath = "/oauth2/refreshToken" + MCPRevokeTokenPath = "/oauth2/revokeToken" ) +// GetMCPBaseURL returns the MCP base URL with priority: +// 1. ~/.dws/mcp_url file content (for pre-release environment) +// 2. Default value (https://mcp.dingtalk.com) +func GetMCPBaseURL() string { + mcpURLPath := filepath.Join(getDefaultConfigDir(), "mcp_url") + if data, err := os.ReadFile(mcpURLPath); err == nil { + if url := strings.TrimSpace(string(data)); url != "" { + return url + } + } + return DefaultMCPBaseURL +} + // Runtime overrides set via CLI flags (--client-id, --client-secret). // These take highest priority over environment variables and defaults. var ( clientMu sync.RWMutex runtimeClientID string runtimeClientSecret string + // clientIDFromMCP indicates whether the clientID was fetched from MCP server. + // When true, MCP OAuth endpoints should be used instead of direct DingTalk API. + clientIDFromMCP bool ) +// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced. +func SetClientIDFromMCP(id string) { + clientMu.Lock() + defer clientMu.Unlock() + runtimeClientID = id + clientIDFromMCP = true +} + +// IsClientIDFromMCP returns true if the current clientID was fetched from MCP server. +func IsClientIDFromMCP() bool { + clientMu.RLock() + defer clientMu.RUnlock() + return clientIDFromMCP +} + +// GetUserAccessTokenURL returns the appropriate token exchange URL. +// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API. +func GetUserAccessTokenURL() string { + if IsClientIDFromMCP() { + return GetMCPBaseURL() + MCPOAuthTokenPath + } + return UserAccessTokenURL +} + +// GetRefreshTokenURL returns the appropriate token refresh URL. +// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API. +func GetRefreshTokenURL() string { + if IsClientIDFromMCP() { + return GetMCPBaseURL() + MCPRefreshTokenPath + } + return UserAccessTokenURL // DingTalk uses same endpoint for refresh +} + +// GetRevokeTokenURL returns the token revocation URL (MCP only). +// Returns empty string if not using MCP mode. +func GetRevokeTokenURL() string { + if IsClientIDFromMCP() { + return GetMCPBaseURL() + MCPRevokeTokenPath + } + return "" // Direct mode doesn't have revoke endpoint +} + // SetClientID allows runtime override of the client ID (e.g., from CLI flags). func SetClientID(id string) { clientMu.Lock() @@ -87,7 +158,9 @@ func SetClientSecret(secret string) { // 1. Runtime override (CLI flag --client-id) // 2. Persisted app config (from previous login) // 3. Environment variable (DWS_CLIENT_ID) -// 4. Default hardcoded value +// 4. Default hardcoded value (if not a placeholder) +// Returns empty string if no valid client ID is available. +// Note: MCP server fetch (priority 4 in the full flow) is handled in OAuthProvider.Login() func ClientID() string { clientMu.RLock() override := runtimeClientID @@ -102,7 +175,11 @@ func ClientID() string { if v := os.Getenv("DWS_CLIENT_ID"); v != "" { return v } - return DefaultClientID + // Only return default if it's not a placeholder + if !strings.HasPrefix(DefaultClientID, "<") { + return DefaultClientID + } + return "" } // ClientSecret returns the OAuth client secret with priority: @@ -127,6 +204,13 @@ func ClientSecret() string { return DefaultClientSecret } +// HasValidClientSecret returns true if a valid client secret is available. +// A valid secret is one that is not a placeholder (e.g., ). +func HasValidClientSecret() bool { + secret := ClientSecret() + return secret != "" && !strings.HasPrefix(secret, "<") +} + // getRuntimeCredentials returns the runtime-override credentials if set. // Returns empty strings if no runtime overrides were provided. func getRuntimeCredentials() (clientID, clientSecret string) { diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index a5a97ce7..58b948e4 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -27,6 +27,11 @@ import ( ) func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) { + // Use MCP mode if clientID is from MCP server + if IsClientIDFromMCP() { + return p.exchangeCodeViaMCP(ctx, code) + } + // Direct mode with client secret body := map[string]string{ "clientId": ClientID(), "clientSecret": ClientSecret(), @@ -40,7 +45,28 @@ func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenDa return p.parseTokenResponse(resp) } +// exchangeCodeViaMCP exchanges auth code for token via MCP proxy. +// This is used when client secret is not available (server-side secret management). +func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) { + url := GetMCPBaseURL() + MCPOAuthTokenPath + body := map[string]string{ + "clientId": ClientID(), + "authCode": code, + "grantType": "authorization_code", + } + resp, err := p.postJSON(ctx, url, body) + if err != nil { + return nil, err + } + return p.parseMCPTokenResponse(resp) +} + func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *TokenData) (*TokenData, error) { + // Use MCP mode if clientID is from MCP server + if IsClientIDFromMCP() { + return p.refreshViaMCP(ctx, data) + } + // Direct mode with client secret body := map[string]string{ "clientId": ClientID(), "clientSecret": ClientSecret(), @@ -67,6 +93,34 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token return updated, nil } +// refreshViaMCP refreshes token via MCP proxy. +func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*TokenData, error) { + url := GetMCPBaseURL() + MCPRefreshTokenPath + body := map[string]string{ + "clientId": ClientID(), + "refreshToken": data.RefreshToken, + "grantType": "refresh_token", + } + resp, err := p.postJSON(ctx, url, body) + if err != nil { + return nil, err + } + updated, err := p.parseMCPTokenResponse(resp) + if err != nil { + return nil, err + } + updated.PersistentCode = data.PersistentCode + updated.CorpID = data.CorpID + updated.UserID = data.UserID + updated.UserName = data.UserName + updated.CorpName = data.CorpName + + if err := SaveTokenData(p.configDir, updated); err != nil { + return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err) + } + return updated, nil +} + func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any) ([]byte, error) { b, err := json.Marshal(body) if err != nil { @@ -132,6 +186,48 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) { return data, nil } +// parseMCPTokenResponse parses token response from MCP proxy. +// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."} +func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) { + var resp struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + PersistentCode string `json:"persistentCode"` + ExpiresIn int64 `json:"expiresIn"` + CorpID string `json:"corpId"` + // Error fields (when request fails) + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parsing MCP token response: %w (body: %s)", err, string(body)) + } + // Check for error response + if resp.ErrorCode != "" || resp.ErrorMsg != "" { + return nil, fmt.Errorf("MCP token exchange failed: %s - %s", resp.ErrorCode, resp.ErrorMsg) + } + if resp.AccessToken == "" { + return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body)) + } + + now := time.Now() + expiresIn := resp.ExpiresIn + if expiresIn <= 0 { + expiresIn = config.DefaultAccessTokenExpiry + } + data := &TokenData{ + AccessToken: resp.AccessToken, + RefreshToken: resp.RefreshToken, + ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second), + RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime), + CorpID: resp.CorpID, + } + if resp.PersistentCode != "" { + data.PersistentCode = resp.PersistentCode + } + return data, nil +} + func buildAuthURL(clientID, redirectURI string) string { params := url.Values{ "client_id": {clientID}, @@ -149,3 +245,336 @@ const successHTML = ` .card{background:#fff;border-radius:12px;padding:40px;text-align:center;box-shadow:0 2px 12px rgba(0,0,0,.08)} h1{color:#1677ff;margin:0 0 8px}p{color:#666;margin:0}

✅ 授权成功

请返回终端继续操作。此页面可以关闭。

` + +const notEnabledHTML = ` +钉钉 CLI + +
+
+

该组织尚未开启 CLI 数据访问权限

+

你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。

+
+ +
+ +
+
+
+
+ +返回选择其他组织 +
+ + + + 已向管理员发送权限申请,正在等待审核
审核通过后,请返回终端继续操作
+
+
+` + +// CLIAuthStatus represents the response from /cli/cliAuthEnabled API. +type CLIAuthStatus struct { + Success bool `json:"success"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + Result struct { + CLIAuthEnabled bool `json:"cliAuthEnabled"` + } `json:"result"` +} + +// SuperAdmin represents a corp super admin. +type SuperAdmin struct { + StaffID string `json:"staffId"` + Name string `json:"name"` +} + +// SuperAdminResponse represents the response from /cli/superAdmin API. +type SuperAdminResponse struct { + Success bool `json:"success"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + Result []SuperAdmin `json:"result"` +} + +// SendApplyResponse represents the response from /cli/sendCliAuthApply API. +type SendApplyResponse struct { + Success bool `json:"success"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + Result bool `json:"result"` +} + +// CheckCLIAuthEnabled checks if CLI authorization is enabled for the current corp. +func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { + url := GetMCPBaseURL() + CLIAuthEnabledPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("x-user-access-token", accessToken) + + client := p.httpClient + if client == nil { + client = oauthHTTPClient + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var status CLIAuthStatus + if err := json.Unmarshal(data, &status); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + return &status, nil +} + +// GetSuperAdmins fetches the list of corp super admins. +func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { + url := GetMCPBaseURL() + SuperAdminPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("x-user-access-token", accessToken) + + resp, err := oauthHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var result SuperAdminResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + return &result, nil +} + +// SendCliAuthApply sends a CLI auth apply request to the specified admin. +func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { + url := GetMCPBaseURL() + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("x-user-access-token", accessToken) + + resp, err := oauthHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var result SendApplyResponse + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + return &result, nil +} + +// ClientIDResponse represents the response from /cli/clientId API. +type ClientIDResponse struct { + Success bool `json:"success"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + Result string `json:"result"` +} + +// FetchClientIDFromMCP fetches the CLI client ID from MCP server. +// This is used when no client ID is provided via flags, config, or env vars. +func FetchClientIDFromMCP(ctx context.Context) (string, error) { + url := GetMCPBaseURL() + ClientIDPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + + resp, err := oauthHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return "", fmt.Errorf("reading response: %w", err) + } + + var result ClientIDResponse + if err := json.Unmarshal(data, &result); err != nil { + return "", fmt.Errorf("parsing response: %w", err) + } + if !result.Success { + return "", fmt.Errorf("%s: %s", result.ErrorCode, result.ErrorMsg) + } + return result.Result, nil +} diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 02dffe6f..cb819f4d 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -15,6 +15,7 @@ package auth import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -22,6 +23,7 @@ import ( "net" "net/http" "os" + "sync" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n" @@ -92,6 +94,23 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro } // Fall through: full browser OAuth flow. + // Ensure we have a valid client ID (fetch from MCP if not available) + if p.clientID == "" { + if p.logger != nil { + p.logger.Debug("client ID not configured, fetching from MCP server") + } + mcpClientID, mcpErr := FetchClientIDFromMCP(ctx) + if mcpErr != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr) + } + p.clientID = mcpClientID + // Mark that clientID is from MCP, so we use MCP OAuth endpoints + SetClientIDFromMCP(mcpClientID) + if p.logger != nil { + p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID) + } + } + // Find a free port for the callback server. listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -100,15 +119,53 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro port := listener.Addr().(*net.TCPAddr).Port redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, CallbackPath) - codeCh := make(chan string, 1) + // Channel to pass callback result (token data or error with CLI auth status) + type callbackResult struct { + token *TokenData + err error + cliAuthDisabled bool + } + resultCh := make(chan callbackResult, 1) errCh := make(chan error, 1) + // Shared state for API handlers (protected by mutex) + var ( + callbackToken *TokenData + callbackProcessed bool + callbackAuthDisabled bool + callbackApplySent bool // Whether apply request was sent + callbackSelectedAdminId string // Selected admin ID for apply + callbackTokenMu sync.Mutex + ) + mux := http.NewServeMux() mux.HandleFunc(CallbackPath, func(w http.ResponseWriter, r *http.Request) { + // Get code first to check if this is a new authorization or page refresh code := r.URL.Query().Get("authCode") if code == "" { code = r.URL.Query().Get("code") } + + // Check if this is a page refresh (no code) and callback was already processed + callbackTokenMu.Lock() + if code == "" && callbackProcessed { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if callbackAuthDisabled { + _, _ = fmt.Fprint(w, notEnabledHTML) + } else { + _, _ = fmt.Fprint(w, successHTML) + } + callbackTokenMu.Unlock() + return + } + // Reset state for new authorization (user switched org) + if code != "" && callbackProcessed { + callbackProcessed = false + callbackApplySent = false + callbackSelectedAdminId = "" + } + callbackTokenMu.Unlock() + if code == "" { select { case errCh <- errors.New(i18n.T("回调中未收到授权码")): @@ -118,24 +175,147 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro _, _ = fmt.Fprint(w, i18n.T("授权失败:未收到授权码")) return } - // Write response FIRST before notifying main goroutine - // This prevents race condition where server.Shutdown is called - // before response is fully sent + + // Exchange code for token immediately in callback + tokenData, exchangeErr := p.exchangeCode(ctx, code) + if exchangeErr != nil { + // Check if we already have a processed state (authCode reused on refresh) + callbackTokenMu.Lock() + if callbackProcessed { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if callbackAuthDisabled { + _, _ = fmt.Fprint(w, notEnabledHTML) + } else { + _, _ = fmt.Fprint(w, successHTML) + } + callbackTokenMu.Unlock() + return + } + callbackTokenMu.Unlock() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprintf(w, "

授权失败

%s

", exchangeErr.Error()) + select { + case resultCh <- callbackResult{err: exchangeErr}: + default: + } + return + } + + // Check CLI auth enabled status + authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken) + cliAuthDisabled := statusErr == nil && authStatus.Success && !authStatus.Result.CLIAuthEnabled + + // Store token and state for API handlers and refresh handling + callbackTokenMu.Lock() + callbackToken = tokenData + callbackProcessed = true + callbackAuthDisabled = cliAuthDisabled + callbackTokenMu.Unlock() + + // Display appropriate HTML based on CLI auth status w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = fmt.Fprint(w, successHTML) + if cliAuthDisabled { + _, _ = fmt.Fprint(w, notEnabledHTML) + } else { + _, _ = fmt.Fprint(w, successHTML) + } // Ensure response is flushed to client if f, ok := w.(http.Flusher); ok { f.Flush() } - // Now notify main goroutine (non-blocking) + // Notify main goroutine with full result select { - case codeCh <- code: - // Success + case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: cliAuthDisabled}: default: - // Select already exited (timeout/cancel); code discarded but response already sent } }) + // API endpoint: get super admins + mux.HandleFunc("/api/superAdmin", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + callbackTokenMu.Lock() + token := callbackToken + callbackTokenMu.Unlock() + if token == nil { + _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) + return + } + result, err := GetSuperAdmins(ctx, token.AccessToken) + if err != nil { + _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) + return + } + data, _ := json.Marshal(result) + _, _ = w.Write(data) + }) + + // API endpoint: send CLI auth apply + mux.HandleFunc("/api/sendApply", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + adminStaffID := r.URL.Query().Get("adminStaffId") + if adminStaffID == "" { + _, _ = w.Write([]byte(`{"success":false,"errorMsg":"缺少 adminStaffId 参数"}`)) + return + } + callbackTokenMu.Lock() + token := callbackToken + callbackTokenMu.Unlock() + if token == nil { + _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) + return + } + result, err := SendCliAuthApply(ctx, token.AccessToken, adminStaffID) + if err != nil { + _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) + return + } + // Mark apply as sent and save selected admin on success + if result.Success && result.Result { + callbackTokenMu.Lock() + callbackApplySent = true + callbackSelectedAdminId = adminStaffID + callbackTokenMu.Unlock() + } + data, _ := json.Marshal(result) + _, _ = w.Write(data) + }) + + // API endpoint: get current status (clientId, applySent, selectedAdminId) + mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + callbackTokenMu.Lock() + applySent := callbackApplySent + selectedAdminId := callbackSelectedAdminId + callbackTokenMu.Unlock() + _, _ = fmt.Fprintf(w, `{"clientId":"%s","applySent":%t,"selectedAdminId":"%s"}`, p.clientID, applySent, selectedAdminId) + }) + + // API endpoint: check CLI auth enabled status + mux.HandleFunc("/api/cliAuthEnabled", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + callbackTokenMu.Lock() + token := callbackToken + callbackTokenMu.Unlock() + if token == nil { + _, _ = w.Write([]byte(`{"success":false,"errorMsg":"授权尚未完成"}`)) + return + } + result, err := p.CheckCLIAuthEnabled(ctx, token.AccessToken) + if err != nil { + _, _ = fmt.Fprintf(w, `{"success":false,"errorMsg":"%s"}`, err.Error()) + return + } + data, _ := json.Marshal(result) + _, _ = w.Write(data) + }) + + // Success page endpoint + mux.HandleFunc("/success", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = fmt.Fprint(w, successHTML) + }) + server := &http.Server{Handler: mux} go func() { if serveErr := server.Serve(listener); !errors.Is(serveErr, http.ErrServerClosed) { @@ -169,9 +349,9 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro timeout := time.NewTimer(5 * time.Minute) defer timeout.Stop() - var authCode string + var result callbackResult select { - case authCode = <-codeCh: + case result = <-resultCh: case err := <-errCh: return nil, err case <-timeout.C: @@ -180,11 +360,73 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro return nil, ctx.Err() } - tokenData, err := p.exchangeCode(ctx, authCode) - if err != nil { - return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err) + // Handle callback errors + if result.err != nil { + return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), result.err) } + // Handle CLI auth disabled - keep server running for user to apply + if result.cliAuthDisabled { + _, _ = fmt.Fprintln(p.output(), "") + _, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...")) + + // Poll for CLI auth status while waiting + applyTimeout := time.NewTimer(10 * time.Minute) + defer applyTimeout.Stop() + pollTicker := time.NewTicker(5 * time.Second) + defer pollTicker.Stop() + + elapsedSeconds := 0 + for { + select { + case <-applyTimeout.C: + return nil, errors.New(i18n.T("操作超时,请重新登录")) + case <-ctx.Done(): + return nil, ctx.Err() + case <-pollTicker.C: + elapsedSeconds += 5 + + // Get latest token and state (user may have switched org) + callbackTokenMu.Lock() + currentToken := callbackToken + currentAuthDisabled := callbackAuthDisabled + applySent := callbackApplySent + callbackTokenMu.Unlock() + + // Check if user switched to an org with CLI auth enabled + if currentToken != nil && !currentAuthDisabled { + _, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录...")) + time.Sleep(2 * time.Second) + result.token = currentToken + result.cliAuthDisabled = false + goto continueLogin + } + + // Check if CLI auth is now enabled (admin approved) + if currentToken != nil { + authStatus, err := p.CheckCLIAuthEnabled(ctx, currentToken.AccessToken) + if err == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled { + _, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录...")) + time.Sleep(2 * time.Second) + result.token = currentToken + result.cliAuthDisabled = false + goto continueLogin + } + } + + // Show polling status based on apply state + if applySent { + _, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待管理员审批中"), elapsedSeconds) + } else { + _, _ = fmt.Fprintf(p.output(), "\r⏳ %s (%ds/600s) ", i18n.T("等待提交申请中"), elapsedSeconds) + } + } + } + } + +continueLogin: + tokenData := result.token + // Save token data with associated client ID for refresh tokenData.ClientID = p.clientID if err := SaveTokenData(p.configDir, tokenData); err != nil { diff --git a/internal/auth/token.go b/internal/auth/token.go index 644eb638..4b7f9472 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -14,7 +14,9 @@ package auth import ( + "bytes" "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -103,10 +105,16 @@ func DeleteTokenData(configDir string) error { return legacyErr } -// RevokeTokenRemote calls the DingTalk logout endpoint to invalidate the access token. +// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token. +// Uses MCP revoke endpoint when clientID is from MCP, otherwise uses DingTalk logout. // This should be called before deleting local token data. // The function is best-effort: errors are returned but callers may choose to ignore them. func RevokeTokenRemote(ctx context.Context) error { + // Use MCP revoke endpoint when clientID is from MCP + if IsClientIDFromMCP() { + return revokeTokenViaMCP(ctx) + } + // Direct mode: use DingTalk logout endpoint logoutURL, err := url.Parse(LogoutURL) if err != nil { return fmt.Errorf("parsing logout URL: %w", err) @@ -143,3 +151,45 @@ func RevokeTokenRemote(ctx context.Context) error { return nil } + +// revokeTokenViaMCP revokes token via MCP endpoint. +func revokeTokenViaMCP(ctx context.Context) error { + revokeURL := GetRevokeTokenURL() + if revokeURL == "" { + return nil // No revoke endpoint available + } + + // Load current token to get accessToken + tokenData, err := LoadTokenData(getDefaultConfigDir()) + if err != nil || tokenData == nil { + return nil // No token to revoke + } + + body := map[string]string{ + "clientId": ClientID(), + "accessToken": tokenData.AccessToken, + } + bodyBytes, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshaling revoke request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("creating revoke request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("calling revoke endpoint: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("revoke endpoint returned status %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 9ba0a6a0..38e90ac2 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -253,6 +253,12 @@ func PrintJSON(w io.Writer, err error) error { } if typed.ServerDiag.ServerErrorCode != "" { errorPayload["server_error_code"] = typed.ServerDiag.ServerErrorCode + // Add user-friendly hint for specific server error codes + switch typed.ServerDiag.ServerErrorCode { + case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED": + errorPayload["friendly_hint"] = "该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。" + errorPayload["action_url"] = "https://open-dev.dingtalk.com/fe/old#/developerSettings" + } } if typed.ServerDiag.TechnicalDetail != "" { errorPayload["technical_detail"] = typed.ServerDiag.TechnicalDetail @@ -312,6 +318,14 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error { if typed.Hint != "" { lines = append(lines, fmt.Sprintf("Hint: %s", typed.Hint)) } + + // Add user-friendly hint for specific server error codes + switch typed.ServerDiag.ServerErrorCode { + case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED": + lines = append(lines, "Hint: 该组织尚未开启 CLI 数据访问权限,请联系组织主管理员开启。") + lines = append(lines, "Action: 开启地址: https://open-dev.dingtalk.com/fe/old#/developerSettings") + } + if len(typed.Actions) > 0 { for _, action := range typed.Actions { if strings.TrimSpace(action) == "" { diff --git a/test/cli/root_command_test.go b/test/cli/root_command_test.go index 52f77304..ad4632c5 100644 --- a/test/cli/root_command_test.go +++ b/test/cli/root_command_test.go @@ -32,16 +32,21 @@ func TestHiddenMCPHelpIsReachable(t *testing.T) { } } -func TestSkillCommandIsNotRegisteredInPublicOSSBuild(t *testing.T) { +func TestSkillCommandIsRegistered(t *testing.T) { t.Parallel() root := app.NewRootCommand() + var found bool for _, cmd := range root.Commands() { if cmd.Name() == "skill" { - t.Fatalf("skill command should not be registered in OSS build") + found = true + break } } + if !found { + t.Fatalf("skill command should be registered") + } } // TestSchemaJSONBootstrapOutput was removed