Merge pull request #58 from fantiu/feat-login-upgrade
Feat login upgrade
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user