From 95d495f290d9ece18699271442c78109bae99523 Mon Sep 17 00:00:00 2001 From: fantiu Date: Thu, 16 Apr 2026 19:49:28 +0800 Subject: [PATCH 1/3] feat: supports claw-like products. --- internal/app/runner.go | 16 ++ internal/auth/cli_auth_check_test.go | 38 ++--- internal/auth/device_flow.go | 74 ++++++--- internal/auth/endpoints.go | 10 ++ internal/auth/oauth_helpers.go | 236 ++++++++++++++++++++++++++- internal/auth/oauth_provider.go | 23 ++- 6 files changed, 334 insertions(+), 63 deletions(-) diff --git a/internal/app/runner.go b/internal/app/runner.go index 36ac7e3d..e0702f8a 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -88,6 +88,10 @@ const ( envDingtalkTraceID = "DINGTALK_TRACE_ID" envDingtalkSessionID = "DINGTALK_SESSION_ID" envDingtalkMessageID = "DINGTALK_MESSAGE_ID" + + // Environment variables for third-party channel integration + envDWSChannel = "DWS_CHANNEL" + envDWSChannelClientID = "DWS_CHANNEL_CLIENT_ID" ) func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner { @@ -449,6 +453,18 @@ func resolveIdentityHeaders() map[string]string { headers[k] = v } } + + // Inject third-party channel headers + channelHeaders := map[string]string{ + "x-dws-channel": os.Getenv(envDWSChannel), + "x-dws-channel-client-id": os.Getenv(envDWSChannelClientID), + } + for k, v := range channelHeaders { + if v != "" { + headers[k] = v + } + } + if fn := edition.Get().MergeHeaders; fn != nil { headers = fn(headers) } diff --git a/internal/auth/cli_auth_check_test.go b/internal/auth/cli_auth_check_test.go index ca076908..73b0e33e 100644 --- a/internal/auth/cli_auth_check_test.go +++ b/internal/auth/cli_auth_check_test.go @@ -154,9 +154,7 @@ func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: true}, + Result: &CLIAuthResult{CLIAuthEnabled: true}, }) })) defer srv.Close() @@ -169,7 +167,7 @@ func TestCheckCLIAuthEnabled_TransientThenSuccess(t *testing.T) { if err != nil { t.Fatalf("expected success after transient failures, got error: %v", err) } - if !status.Success || !status.Result.CLIAuthEnabled { + if !status.Success || status.Result == nil || !status.Result.CLIAuthEnabled { t.Fatalf("expected CLIAuthEnabled=true, got %+v", status) } if c := calls.Load(); c != 3 { @@ -190,9 +188,7 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: true}, + Result: &CLIAuthResult{CLIAuthEnabled: true}, }) })) defer srv.Close() @@ -204,7 +200,7 @@ func TestCheckCLIAuthEnabled_Enabled(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if !status.Result.CLIAuthEnabled { + if status.Result == nil || !status.Result.CLIAuthEnabled { t.Fatal("expected CLIAuthEnabled=true") } t.Logf("✅ Normal enabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled) @@ -215,9 +211,7 @@ func TestCheckCLIAuthEnabled_Disabled(t *testing.T) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: false}, + Result: &CLIAuthResult{CLIAuthEnabled: false}, }) })) defer srv.Close() @@ -229,7 +223,7 @@ func TestCheckCLIAuthEnabled_Disabled(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if status.Result.CLIAuthEnabled { + if status.Result == nil || status.Result.CLIAuthEnabled { t.Fatal("expected CLIAuthEnabled=false") } t.Logf("✅ Normal disabled response: success=%v, enabled=%v", status.Success, status.Result.CLIAuthEnabled) @@ -262,12 +256,10 @@ func TestOAuthCallback_CLIAuthEnabled_ShowsSuccessPage(t *testing.T) { var statusErr error authStatus := &CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: true}, + Result: &CLIAuthResult{CLIAuthEnabled: true}, } - cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled + cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled if !cliAuthEnabled { t.Fatal("cliAuthEnabled should be true when API returns enabled") } @@ -280,12 +272,10 @@ func TestOAuthCallback_CLIAuthDisabledByServer_ShowsNotEnabledPage(t *testing.T) var statusErr error authStatus := &CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: false}, + Result: &CLIAuthResult{CLIAuthEnabled: false}, } - cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled + cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result != nil && authStatus.Result.CLIAuthEnabled if cliAuthEnabled { t.Fatal("cliAuthEnabled should be false when server says disabled") } @@ -396,9 +386,7 @@ func TestDeviceFlow_LoginOnce_CLIAuthDisabled_ShowsError(t *testing.T) { case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath): json.NewEncoder(w).Encode(CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: false}, + Result: &CLIAuthResult{CLIAuthEnabled: false}, }) case strings.HasSuffix(r.URL.Path, SuperAdminPath): @@ -468,9 +456,7 @@ func TestDeviceFlow_LoginOnce_CLIAuthEnabled_Success(t *testing.T) { case strings.HasSuffix(r.URL.Path, CLIAuthEnabledPath): json.NewEncoder(w).Encode(CLIAuthStatus{ Success: true, - Result: struct { - CLIAuthEnabled bool `json:"cliAuthEnabled"` - }{CLIAuthEnabled: true}, + Result: &CLIAuthResult{CLIAuthEnabled: true}, }) default: diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 8b7a0d6c..c1c2fc0b 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -176,33 +176,61 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token _, _ = fmt.Fprintln(p.output(), i18n.T(" 请检查网络连接后重试。")) _, _ = fmt.Fprintln(p.output(), "") return nil, fmt.Errorf("%s: %w", i18n.T("检查 CLI 授权状态失败"), authErr) - } 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 访问其个人数据」的权限。")) + } + denialReason := classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) + if denialReason != "" { _, _ = fmt.Fprintln(p.output(), "") + switch denialReason { + case "user_forbidden": + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 该组织已禁止所有成员使用 CLI"))) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("该组织已禁止所有成员使用 CLI")) + case "user_not_allowed": + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 您不在该组织的 CLI 授权人员范围内"))) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员将您加入 CLI 授权人员名单。")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员")) + case "channel_not_allowed": + ch := os.Getenv("DWS_CHANNEL") + _, _ = fmt.Fprintf(p.output(), dfRed(i18n.T("⚠️ 当前渠道 %s 未获得该组织授权"))+"\n", ch) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请联系组织管理员开通该渠道的访问权限。")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, fmt.Errorf(i18n.T("当前渠道 %s 未获得该组织授权,请联系组织管理员"), ch) + case "channel_required": + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 当前组织已开启渠道管控"))) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试")) + case "no_auth": + _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效"))) + _, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。")) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(i18n.T("认证已失效,请执行 dws auth 重新登录")) + default: + // cli_not_enabled or unknown — show existing admin-apply flow + _, _ = 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) + admins, adminErr := GetSuperAdmins(ctx, tokenData.AccessToken) + if adminErr == nil && admins.Success && len(admins.Result) > 0 { + 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, "、")) } - 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 数据访问权限,请联系管理员开启")) } - - _, _ = 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 diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 0014862d..0f427088 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -38,6 +38,16 @@ func init() { DefaultValue: "(内置)", Sensitive: true, }) + configmeta.Register(configmeta.ConfigItem{ + Name: "DWS_CHANNEL", + Category: configmeta.CategoryExternal, + Description: "第三方渠道编码 (channelCode),如 Qoderwork", + }) + configmeta.Register(configmeta.ConfigItem{ + Name: "DWS_CHANNEL_CLIENT_ID", + Category: configmeta.CategoryExternal, + Description: "第三方渠道在组织内创建的应用 clientID", + }) } const ( diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index 0a2fceab..e84a9a68 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -21,11 +21,42 @@ import ( "io" "net/http" "net/url" + "os" + "path/filepath" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) +// debugLogMCPRequest logs MCP API request and response to ~/.dws/debug.log for debugging. +// TODO: Remove before official release. +func debugLogMCPRequest(endpoint string, headers map[string]string, respStatus int, respBody []byte) { + homeDir, err := os.UserHomeDir() + if err != nil { + return + } + logPath := filepath.Join(homeDir, ".dws", "debug.log") + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return + } + defer f.Close() + + timestamp := time.Now().Format("2006-01-02 15:04:05.000") + fmt.Fprintf(f, "\n========== %s ==========\n", timestamp) + fmt.Fprintf(f, "Endpoint: %s\n", endpoint) + fmt.Fprintf(f, "Request Headers:\n") + for k, v := range headers { + if k == "x-user-access-token" && len(v) > 20 { + fmt.Fprintf(f, " %s: %s...%s (len=%d)\n", k, v[:10], v[len(v)-10:], len(v)) + } else { + fmt.Fprintf(f, " %s: %s\n", k, v) + } + } + fmt.Fprintf(f, "Response Status: %d\n", respStatus) + fmt.Fprintf(f, "Response Body:\n%s\n", string(respBody)) +} + func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) { // Use MCP mode if clientID is from MCP server if IsClientIDFromMCP() { @@ -919,14 +950,189 @@ const notEnabledHTML = ` ` +const accessDeniedHTML = ` + + + + + 钉钉 CLI + + + +
+ lock icon +

无权限访问

+

您不在该组织的 CLI 授权人员范围内。
请联系组织管理员将您加入授权名单。

+

此页面可以关闭。

+
+ +` + +const channelDeniedHTML = ` + + + + + 钉钉 CLI + + + +
+ lock icon +

渠道未授权

+

当前渠道未获得该组织授权,或组织已开启渠道管控。
请联系组织管理员开通渠道访问权限,或升级到最新版本的 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"` + Success bool `json:"success"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` + Result *CLIAuthResult `json:"result"` +} + +// CLIAuthResult holds the business data returned by /cli/cliAuthEnabled. +// The server computes cliAuthEnabled by considering the org switch, userScope, +// and channelScope together; the CLI uses it as-is. +type CLIAuthResult struct { + CLIAuthEnabled bool `json:"cliAuthEnabled"` + UserScope string `json:"userScope,omitempty"` // "all" | "specified" | "forbidden" + AllowedUsers []string `json:"allowedUsers,omitempty"` // staffId list when userScope="specified" + ChannelScope string `json:"channelScope,omitempty"` // "all" | "specified" + AllowedChannels []string `json:"allowedChannels,omitempty"` // channelCode list when channelScope="specified" +} + +// classifyDenialReason inspects a CLIAuthStatus response and returns a machine-readable +// denial reason string. Returns "" when access is granted. +func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string { + if status.ErrorCode == "CHANNEL_REQUIRED" { + return "channel_required" + } + if status.ErrorCode == "NO_AUTH" { + return "no_auth" + } + if status.Result == nil || !status.Success { + return "unknown" + } + if status.Result.CLIAuthEnabled { + return "" + } + // cliAuthEnabled=false — infer reason from auxiliary fields (same priority as server) + if status.Result.UserScope == "forbidden" { + return "user_forbidden" + } + if status.Result.UserScope == "specified" { + return "user_not_allowed" + } + if status.Result.ChannelScope == "specified" && currentChannel != "" { + return "channel_not_allowed" + } + return "cli_not_enabled" } // SuperAdmin represents a corp super admin. @@ -979,12 +1185,23 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str } func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { - url := GetMCPBaseURL() + CLIAuthEnabledPath - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + reqURL := GetMCPBaseURL() + CLIAuthEnabledPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("x-user-access-token", accessToken) + if ch := os.Getenv("DWS_CHANNEL"); ch != "" { + req.Header.Set("x-dws-channel", ch) + } + + // Collect headers for debug logging + debugHeaders := map[string]string{ + "x-user-access-token": accessToken, + } + if ch := os.Getenv("DWS_CHANNEL"); ch != "" { + debugHeaders["x-dws-channel"] = ch + } client := p.httpClient if client == nil { @@ -1001,6 +1218,9 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s return nil, fmt.Errorf("reading response: %w", err) } + // Log request and response for debugging (TODO: remove before release) + debugLogMCPRequest(reqURL, debugHeaders, resp.StatusCode, data) + var status CLIAuthStatus if err := json.Unmarshal(data, &status); err != nil { return nil, fmt.Errorf("parsing response: %w", err) diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 071f9237..bf422bea 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -236,19 +236,30 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro // Check CLI auth enabled status (fail-closed: treat errors as disabled) authStatus, statusErr := p.CheckCLIAuthEnabled(ctx, tokenData.AccessToken) - cliAuthEnabled := statusErr == nil && authStatus.Success && authStatus.Result.CLIAuthEnabled + var denialReason string + if statusErr != nil { + denialReason = "unknown" + } else { + denialReason = classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) + } + cliAuthEnabled := denialReason == "" // Update CLI auth disabled state callbackTokenMu.Lock() callbackAuthDisabled = !cliAuthEnabled callbackTokenMu.Unlock() - // Display appropriate HTML based on CLI auth status + // Display appropriate HTML based on auth status and denial reason w.Header().Set("Content-Type", "text/html; charset=utf-8") - if !cliAuthEnabled { - _, _ = fmt.Fprint(w, notEnabledHTML) - } else { + switch { + case cliAuthEnabled: _, _ = fmt.Fprint(w, successHTML) + case denialReason == "user_forbidden" || denialReason == "user_not_allowed": + _, _ = fmt.Fprint(w, accessDeniedHTML) + case denialReason == "channel_not_allowed" || denialReason == "channel_required": + _, _ = fmt.Fprint(w, channelDeniedHTML) + default: + _, _ = fmt.Fprint(w, notEnabledHTML) } // Ensure response is flushed to client if f, ok := w.(http.Flusher); ok { @@ -435,7 +446,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro // 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 { + if err == nil && classifyDenialReason(authStatus, os.Getenv("DWS_CHANNEL")) == "" { _, _ = fmt.Fprintf(p.output(), "\r%s\n", i18n.T("✅ 权限已开启,继续登录...")) time.Sleep(2 * time.Second) result.token = currentToken From fec0347cd6a8fd9ae209e7380428330290447a1c Mon Sep 17 00:00:00 2001 From: fantiu Date: Fri, 17 Apr 2026 10:15:04 +0800 Subject: [PATCH 2/3] feat(auth):improve login UX for terminal auth denial cases --- internal/app/runner.go | 13 ++---- internal/auth/endpoints.go | 5 --- internal/auth/oauth_helpers.go | 70 +++++++++++++++++++++++++++------ internal/auth/oauth_provider.go | 15 ++++++- 4 files changed, 74 insertions(+), 29 deletions(-) diff --git a/internal/app/runner.go b/internal/app/runner.go index ff82ff15..6ad92fa7 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -90,8 +90,7 @@ const ( envDingtalkMessageID = "DINGTALK_MESSAGE_ID" // Environment variables for third-party channel integration - envDWSChannel = "DWS_CHANNEL" - envDWSChannelClientID = "DWS_CHANNEL_CLIENT_ID" + envDWSChannel = "DWS_CHANNEL" ) func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) executor.Runner { @@ -547,14 +546,8 @@ func resolveIdentityHeaders() map[string]string { } // Inject third-party channel headers - channelHeaders := map[string]string{ - "x-dws-channel": os.Getenv(envDWSChannel), - "x-dws-channel-client-id": os.Getenv(envDWSChannelClientID), - } - for k, v := range channelHeaders { - if v != "" { - headers[k] = v - } + if v := os.Getenv(envDWSChannel); v != "" { + headers["x-dws-channel"] = v } if fn := edition.Get().MergeHeaders; fn != nil { diff --git a/internal/auth/endpoints.go b/internal/auth/endpoints.go index 0f427088..bcc5500f 100644 --- a/internal/auth/endpoints.go +++ b/internal/auth/endpoints.go @@ -43,11 +43,6 @@ func init() { Category: configmeta.CategoryExternal, Description: "第三方渠道编码 (channelCode),如 Qoderwork", }) - configmeta.Register(configmeta.ConfigItem{ - Name: "DWS_CHANNEL_CLIENT_ID", - Category: configmeta.CategoryExternal, - Description: "第三方渠道在组织内创建的应用 clientID", - }) } const ( diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index e84a9a68..b8706c76 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -970,20 +970,21 @@ const accessDeniedHTML = ` padding: 20px; } .card { + height: 600px; width: 480px; border-radius: 16px; background: #ffffff; box-sizing: border-box; border: 1px solid #f2f2f6; box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12); - padding: 48px 24px 40px; + padding: 32px 24px 24px; text-align: center; display: flex; justify-content: center; align-items: center; flex-direction: column; } - .icon { + .lock-icon { width: 120px; height: 120px; margin: 0 auto; @@ -992,15 +993,38 @@ const accessDeniedHTML = ` } h1 { margin: 8px 0 0; + font-family: + "PingFang SC", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + sans-serif; font-size: 18px; font-weight: 600; line-height: 44px; + text-align: center; + letter-spacing: normal; color: #181c1f; } p { - margin: 8px 0 0; + margin: 0; + font-family: + "PingFang SC", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + sans-serif; font-size: 14px; + font-weight: normal; line-height: 21px; + text-align: center; + letter-spacing: normal; color: rgba(24, 28, 31, 0.6); } @@ -1008,13 +1032,12 @@ const accessDeniedHTML = `
lock icon

无权限访问

-

您不在该组织的 CLI 授权人员范围内。
请联系组织管理员将您加入授权名单。

-

此页面可以关闭。

+

您不在该组织的 CLI 授权人员范围内。请联系组织管理员将您加入授权名单。此页面可以关闭。

` @@ -1039,20 +1062,21 @@ const channelDeniedHTML = ` padding: 20px; } .card { + height: 600px; width: 480px; border-radius: 16px; background: #ffffff; box-sizing: border-box; border: 1px solid #f2f2f6; box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12); - padding: 48px 24px 40px; + padding: 32px 24px 24px; text-align: center; display: flex; justify-content: center; align-items: center; flex-direction: column; } - .icon { + .lock-icon { width: 120px; height: 120px; margin: 0 auto; @@ -1061,15 +1085,38 @@ const channelDeniedHTML = ` } h1 { margin: 8px 0 0; + font-family: + "PingFang SC", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + sans-serif; font-size: 18px; font-weight: 600; line-height: 44px; + text-align: center; + letter-spacing: normal; color: #181c1f; } p { - margin: 8px 0 0; + margin: 0; + font-family: + "PingFang SC", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + sans-serif; font-size: 14px; + font-weight: normal; line-height: 21px; + text-align: center; + letter-spacing: normal; color: rgba(24, 28, 31, 0.6); } @@ -1077,13 +1124,12 @@ const channelDeniedHTML = `
lock icon

渠道未授权

-

当前渠道未获得该组织授权,或组织已开启渠道管控。
请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI。

-

此页面可以关闭。

+

当前渠道未获得该组织授权,或组织已开启渠道管控。请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI。此页面可以关闭。

` diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index bf422bea..34112feb 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -124,6 +124,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro token *TokenData err error cliAuthDisabled bool + denialReason string } resultCh := make(chan callbackResult, 1) errCh := make(chan error, 1) @@ -267,7 +268,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro } // Notify main goroutine with full result select { - case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled}: + case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason}: default: } }) @@ -406,8 +407,18 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), result.err) } - // Handle CLI auth disabled - keep server running for user to apply + // Handle CLI auth disabled - for terminal denial reasons, exit immediately + // (page shows accessDeniedHTML/channelDeniedHTML with no apply button, + // so polling for apply submission would hang forever). + // Error messages are kept consistent with the text shown on the HTML pages. if result.cliAuthDisabled { + switch result.denialReason { + case "user_forbidden", "user_not_allowed": + return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单")) + case "channel_not_allowed", "channel_required": + return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI")) + } + _, _ = fmt.Fprintln(p.output(), "") _, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...")) From 4f1ee375084fce89f9aa43996853995cae8cd2d5 Mon Sep 17 00:00:00 2001 From: fantiu Date: Fri, 17 Apr 2026 10:20:35 +0800 Subject: [PATCH 3/3] fix(auth): improve login UX for terminal auth denial cases --- internal/auth/oauth_helpers.go | 45 ++-------------------------------- 1 file changed, 2 insertions(+), 43 deletions(-) diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index b8706c76..3966bae2 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -22,41 +22,11 @@ import ( "net/http" "net/url" "os" - "path/filepath" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) -// debugLogMCPRequest logs MCP API request and response to ~/.dws/debug.log for debugging. -// TODO: Remove before official release. -func debugLogMCPRequest(endpoint string, headers map[string]string, respStatus int, respBody []byte) { - homeDir, err := os.UserHomeDir() - if err != nil { - return - } - logPath := filepath.Join(homeDir, ".dws", "debug.log") - f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) - if err != nil { - return - } - defer f.Close() - - timestamp := time.Now().Format("2006-01-02 15:04:05.000") - fmt.Fprintf(f, "\n========== %s ==========\n", timestamp) - fmt.Fprintf(f, "Endpoint: %s\n", endpoint) - fmt.Fprintf(f, "Request Headers:\n") - for k, v := range headers { - if k == "x-user-access-token" && len(v) > 20 { - fmt.Fprintf(f, " %s: %s...%s (len=%d)\n", k, v[:10], v[len(v)-10:], len(v)) - } else { - fmt.Fprintf(f, " %s: %s\n", k, v) - } - } - fmt.Fprintf(f, "Response Status: %d\n", respStatus) - fmt.Fprintf(f, "Response Body:\n%s\n", string(respBody)) -} - func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) { // Use MCP mode if clientID is from MCP server if IsClientIDFromMCP() { @@ -1231,8 +1201,8 @@ func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken str } func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { - reqURL := GetMCPBaseURL() + CLIAuthEnabledPath - req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + url := GetMCPBaseURL() + CLIAuthEnabledPath + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } @@ -1241,14 +1211,6 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s req.Header.Set("x-dws-channel", ch) } - // Collect headers for debug logging - debugHeaders := map[string]string{ - "x-user-access-token": accessToken, - } - if ch := os.Getenv("DWS_CHANNEL"); ch != "" { - debugHeaders["x-dws-channel"] = ch - } - client := p.httpClient if client == nil { client = oauthHTTPClient @@ -1264,9 +1226,6 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s return nil, fmt.Errorf("reading response: %w", err) } - // Log request and response for debugging (TODO: remove before release) - debugLogMCPRequest(reqURL, debugHeaders, resp.StatusCode, data) - var status CLIAuthStatus if err := json.Unmarshal(data, &status); err != nil { return nil, fmt.Errorf("parsing response: %w", err)