Merge pull request #58 from fantiu/feat-login-upgrade

Feat login upgrade
This commit is contained in:
fantiu
2026-04-01 00:53:52 +08:00
committed by GitHub
15 changed files with 1041 additions and 47 deletions
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.3%"><title>coverage: 52.3%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">52.3%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.3%</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.8%"><title>coverage: 52.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">52.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.8%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+38 -4
View File
@@ -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
}
+15
View File
@@ -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
+69 -19
View File
@@ -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 {
+3 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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).
+13 -2
View File
@@ -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)
+54
View File
@@ -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 {
+4
View File
@@ -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)
+86 -2
View File
@@ -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., <YOUR_CLIENT_SECRET>).
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) {
+429
View File
@@ -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 = `<!DOCTYPE html>
.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}</style></head>
<body><div class="card"><h1>✅ 授权成功</h1><p>请返回终端继续操作。此页面可以关闭。</p></div></body></html>`
const notEnabledHTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>钉钉 CLI</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
display:flex;justify-content:center;align-items:center;min-height:100vh;background:#fff;padding:20px}
.container{text-align:center;max-width:400px;width:100%}
.lock-icon{width:80px;height:100px;margin:0 auto 32px;position:relative}
.lock-body{width:60px;height:50px;background:linear-gradient(135deg,#1890ff 0%,#69c0ff 100%);
border-radius:8px;position:absolute;bottom:0;left:50%;transform:translateX(-50%);
box-shadow:0 8px 24px rgba(24,144,255,0.3)}
.lock-body::before{content:"";position:absolute;width:12px;height:12px;background:#fff;
border-radius:50%;top:50%;left:50%;transform:translate(-50%,-50%)}
.lock-body::after{content:"";position:absolute;width:4px;height:12px;background:#fff;
top:50%;left:50%;transform:translateX(-50%);margin-top:4px;border-radius:2px}
.lock-shackle{width:36px;height:36px;border:6px solid #1890ff;border-bottom:none;
border-radius:20px 20px 0 0;position:absolute;top:8px;left:50%;transform:translateX(-50%)}
h1{font-size:22px;font-weight:600;color:#181c1f;margin-bottom:16px;line-height:1.4}
p{font-size:15px;color:#5f6368;line-height:1.6;margin-bottom:24px}
.form-group{text-align:left;margin-bottom:24px}
.form-label{font-size:14px;color:#181c1f;margin-bottom:8px;display:block}
.select-wrapper{position:relative}
select{width:100%;padding:12px 40px 12px 16px;font-size:15px;border:1px solid #d9d9d9;
border-radius:8px;background:#fff;appearance:none;cursor:pointer;color:#181c1f}
select:focus{outline:none;border-color:#1890ff;box-shadow:0 0 0 2px rgba(24,144,255,0.1)}
.select-arrow{position:absolute;right:16px;top:50%;transform:translateY(-50%);pointer-events:none;
border:5px solid transparent;border-top:6px solid #5f6368}
.btn{display:block;width:100%;padding:14px 24px;background:linear-gradient(135deg,#1890ff 0%,#40a9ff 100%);
color:#fff;font-size:16px;font-weight:500;border-radius:24px;border:none;cursor:pointer;
box-shadow:0 4px 16px rgba(24,144,255,0.3);transition:all 0.2s}
.btn:hover{transform:translateY(-1px);box-shadow:0 6px 20px rgba(24,144,255,0.4)}
.btn:disabled{background:#d9d9d9;box-shadow:none;cursor:not-allowed;transform:none}
.link{color:#1890ff;font-size:14px;text-decoration:none;margin-top:16px;display:inline-block}
.link:hover{text-decoration:underline}
.success-msg{display:none;width:100%;min-height:36px;gap:12px;padding:8px 12px;margin-top:20px;
background:linear-gradient(0deg,rgba(0,102,255,0.12) 0%,rgba(0,102,255,0.12) 100%),linear-gradient(0deg,#FFFFFF 0%,#FFFFFF 100%);
border-radius:12px;align-items:flex-start;justify-content:flex-start}
.success-msg-icon{width:16px;height:16px;flex-shrink:0;margin-top:3px}
.success-msg-text{flex:1;color:#181C1F;font-size:14px;line-height:22px}
.error-msg{color:#ff4d4f;font-size:14px;margin-top:8px;display:none}
.loading{display:inline-block;width:16px;height:16px;border:2px solid #fff;border-top-color:transparent;
border-radius:50%;animation:spin 0.8s linear infinite;margin-right:8px;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
</style></head>
<body><div class="container">
<div class="lock-icon"><div class="lock-shackle"></div><div class="lock-body"></div></div>
<h1>该组织尚未开启 CLI 数据访问权限</h1>
<p>你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。</p>
<div class="form-group">
<label class="form-label">你将使用以下账号授权登录</label>
<div class="select-wrapper">
<select id="adminSelect"><option value="">加载中...</option></select>
<div class="select-arrow"></div>
</div>
<div id="errorMsg" class="error-msg"></div>
</div>
<button id="applyBtn" class="btn" disabled>立即申请</button>
<a id="backLink" class="link" href="#">返回选择其他组织</a>
<div id="successMsg" class="success-msg">
<svg class="success-msg-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 1.33333C4.32 1.33333 1.33333 4.32 1.33333 8C1.33333 11.68 4.32 14.6667 8 14.6667C11.68 14.6667 14.6667 11.68 14.6667 8C14.6667 4.32 11.68 1.33333 8 1.33333ZM8 13.3333C5.05333 13.3333 2.66667 10.9467 2.66667 8C2.66667 5.05333 5.05333 2.66667 8 2.66667C10.9467 2.66667 13.3333 5.05333 13.3333 8C13.3333 10.9467 10.9467 13.3333 8 13.3333ZM7.33333 9.33333H8.66667V10.6667H7.33333V9.33333ZM7.33333 5.33333H8.66667V8H7.33333V5.33333Z" fill="#0066FF"/>
</svg>
<span class="success-msg-text">已向管理员发送权限申请,正在等待审核<br/>审核通过后,请返回终端继续操作</span>
</div>
</div>
<script>
const select=document.getElementById('adminSelect'),btn=document.getElementById('applyBtn'),
successMsg=document.getElementById('successMsg'),errorMsg=document.getElementById('errorMsg'),
backLink=document.getElementById('backLink');
let admins=[],clientId='',applySent=false,selectedAdminId='',pollTimer=null;
// Load status first, then load admins
async function init(){
try{
const statusRes=await fetch('/api/status');
const status=await statusRes.json();
clientId=status.clientId||'';
applySent=status.applySent||false;
selectedAdminId=status.selectedAdminId||'';
// Set back link URL
if(clientId){
const port=location.port;
const redirectUri=encodeURIComponent('http://127.0.0.1:'+port+'/callback');
backLink.href='https://login.dingtalk.com/oauth2/auth?client_id='+clientId+
'&prompt=consent&redirect_uri='+redirectUri+'&response_type=code&scope=openid+corpid';
}
// Restore state if apply was sent
if(applySent){
setAppliedState();
}
}catch(e){console.error('Failed to load status',e);}
loadAdmins();
}
function setAppliedState(){
btn.style.display='none';
select.disabled=true;
successMsg.style.display='flex';
backLink.style.pointerEvents='none';
backLink.style.color='#999';
backLink.onclick=function(e){e.preventDefault();return false;};
// Start polling for auth status
startPolling();
}
function startPolling(){
if(pollTimer)return;
pollTimer=setInterval(checkAuthStatus,5000);
// Also check immediately
checkAuthStatus();
}
async function checkAuthStatus(){
try{
const res=await fetch('/api/cliAuthEnabled');
const data=await res.json();
if(data.success&&data.result&&data.result.cliAuthEnabled){
// Auth enabled! Redirect to success page
stopPolling();
location.href='/success';
}
}catch(e){console.error('Poll error',e);
}
}
function stopPolling(){
if(pollTimer){clearInterval(pollTimer);pollTimer=null;}
}
async function loadAdmins(){
try{
const res=await fetch('/api/superAdmin');
const data=await res.json();
if(data.success&&data.result&&data.result.length>0){
admins=data.result;
select.innerHTML='<option value="">请选择</option>'+
admins.map(a=>'<option value="'+a.staffId+'">'+a.name+'</option>').join('');
// Restore selected admin if available
if(selectedAdminId){
select.value=selectedAdminId;
}
btn.disabled=!select.value;
}else{
select.innerHTML='<option value="">暂无可选管理员</option>';
showError(data.errorMsg||'获取管理员列表失败');
}
}catch(e){
select.innerHTML='<option value="">加载失败</option>';
showError('网络错误,请重试');
}
}
function showError(msg){errorMsg.textContent=msg;errorMsg.style.display='block';}
function hideError(){errorMsg.style.display='none';}
select.onchange=function(){btn.disabled=!this.value;hideError();};
btn.onclick=async function(){
if(!select.value)return;
btn.disabled=true;
btn.innerHTML='<span class="loading"></span>申请中...';
hideError();
try{
const res=await fetch('/api/sendApply?adminStaffId='+encodeURIComponent(select.value));
const data=await res.json();
if(data.success&&data.result){
setAppliedState();
}else{
showError(data.errorMsg||'申请失败,请重试');
btn.disabled=false;
btn.textContent='立即申请';
}
}catch(e){
showError('网络错误,请重试');
btn.disabled=false;
btn.textContent='立即申请';
}
};
init();
</script></body></html>`
// 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
}
+256 -14
View File
@@ -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, "<html><body><h1>授权失败</h1><p>%s</p></body></html>", 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 {
+51 -1
View File
@@ -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
}
+14
View File
@@ -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) == "" {
+7 -2
View File
@@ -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