// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package auth import ( "bytes" "context" "encoding/json" "fmt" "html" "io" "net/http" "net/url" "os" "slices" "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) 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 clientID := ClientID() clientSecret := ClientSecret() body := map[string]string{ "clientId": clientID, "clientSecret": clientSecret, "code": code, "grantType": "authorization_code", } resp, err := p.postJSON(ctx, UserAccessTokenURL, body) if err != nil { return nil, err } data, err := p.parseTokenResponse(resp) if err != nil { return nil, err } // Snapshot credentials used for this token (for refresh) data.ClientID = clientID data.Source = resolveCredentialSource() // Save clientSecret for future refresh (even if env changes) if err := SaveClientSecret(clientID, clientSecret); err != nil { // Log warning but don't fail login fmt.Fprintf(p.Output, "Warning: failed to save client secret: %v\n", err) } return data, nil } // ExchangeCodeForToken exchanges an authorization code for token data using // the currently configured client credentials. This is a convenience wrapper // around OAuthProvider.exchangeCode for callers outside the auth package. func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenData, error) { p := &OAuthProvider{ configDir: configDir, clientID: ClientID(), Output: io.Discard, httpClient: oauthHTTPClient, } return p.exchangeCode(ctx, code) } // 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) { clientID := ClientID() 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 } data, err := p.parseMCPTokenResponse(resp) if err != nil { return nil, err } // Snapshot credentials used for this token (for refresh) data.ClientID = clientID data.Source = "mcp" // MCP mode doesn't need to save clientSecret (server-side managed) return data, nil } func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *TokenData) (*TokenData, error) { // Use stored Source to determine refresh path (not current runtime state) // This ensures refresh works even if environment variables changed since login if data.Source == "mcp" { return p.refreshViaMCP(ctx, data) } // Direct mode: use stored clientId and load saved clientSecret clientID := data.ClientID if clientID == "" { // Fallback for legacy tokens without stored clientId clientID = ClientID() } clientSecret := LoadClientSecret(clientID) if clientSecret == "" { // Fallback: try current environment clientSecret = ClientSecret() } if clientID == "" || clientSecret == "" { return nil, fmt.Errorf("无法刷新 token: 缺少 clientId 或 clientSecret,请重新登录") } body := map[string]string{ "clientId": clientID, "clientSecret": clientSecret, "refreshToken": data.RefreshToken, "grantType": "refresh_token", } resp, err := p.postJSON(ctx, UserAccessTokenURL, body) if err != nil { return nil, err } updated, err := p.parseTokenResponse(resp) if err != nil { return nil, err } // Preserve original credentials info updated.ClientID = data.ClientID updated.Source = data.Source updated.PersistentCode = data.PersistentCode updated.CorpID = data.CorpID updated.UserID = data.UserID updated.UserName = data.UserName if updated.CorpName == "" { updated.CorpName = data.CorpName } // Refresh runs under lockedRefresh's dual-layer lock; use the lock-free // saver to avoid re-acquiring the non-reentrant lock (deadlock). if err := saveTokenDataLocked(p.configDir, updated); err != nil { return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err) } return updated, nil } // refreshViaMCP refreshes token via MCP proxy. func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*TokenData, error) { // Use stored clientId from token data clientID := data.ClientID if clientID == "" { // Fallback for legacy tokens clientID = ClientID() } if clientID == "" { return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录") } 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 } // Preserve original credentials info updated.ClientID = data.ClientID updated.Source = data.Source updated.PersistentCode = data.PersistentCode updated.CorpID = data.CorpID updated.UserID = data.UserID updated.UserName = data.UserName if updated.CorpName == "" { updated.CorpName = data.CorpName } // Refresh runs under lockedRefresh's dual-layer lock; use the lock-free // saver to avoid re-acquiring the non-reentrant lock (deadlock). if err := saveTokenDataLocked(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 { return nil, fmt.Errorf("marshaling request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(b)) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("Content-Type", "application/json") applyEditionEnterpriseCredentialHeaders(req) 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) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBody(data, 200)) } return data, nil } func (p *OAuthProvider) parseTokenResponse(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"` } if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("parsing token response: %w", err) } if resp.AccessToken == "" { return nil, fmt.Errorf("token response missing accessToken") } now := time.Now() expiresIn := resp.ExpiresIn if expiresIn <= 0 { // 默认 2 小时有效期(钉钉 access_token 标准有效期) 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 } // parseMCPTokenResponse parses token response from MCP proxy. // MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."} 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"` CorpName string `json:"corpName"` CorpNameSnake string `json:"corp_name"` OrgName string `json:"orgName"` // 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, CorpName: firstNonEmpty(resp.CorpName, resp.CorpNameSnake, resp.OrgName), } if resp.PersistentCode != "" { data.PersistentCode = resp.PersistentCode } return data, nil } func firstNonEmpty(values ...string) string { for _, v := range values { if trimmed := strings.TrimSpace(v); trimmed != "" { return trimmed } } return "" } func buildAuthURL(clientID, redirectURI, targetCorpID string) string { params := url.Values{ "client_id": {clientID}, "redirect_uri": {redirectURI}, "response_type": {"code"}, "scope": {DefaultScopes}, "prompt": {"consent"}, } if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" { params.Set("corpId", targetCorpID) } return AuthorizeURL + "?" + params.Encode() } const successHTML = ` 钉钉 CLI
lock icon

授权成功

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

` const notEnabledHTML = ` 钉钉 CLI
lock icon

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

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

    已向管理员发送权限申请,正在等待审核
    审核通过后,请返回终端继续操作
    返回选择其他组织
    ` const accessDeniedHTML = ` 钉钉 CLI
    lock icon

    无权限访问

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

    ` const channelDeniedHTML = ` 钉钉 CLI
    lock icon

    渠道未授权

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

    ` const enterpriseDeniedHTML = ` 钉钉 CLI
    lock icon

    企业安全认证未通过

    __ENTERPRISE_DENIED_MSG__

    ` // defaultEnterpriseDeniedMsg is shown when the server returns no errorMsg. const defaultEnterpriseDeniedMsg = "本次请求未通过企业安全认证" // renderEnterpriseDeniedHTML injects the server-provided denial message (falling // back to the default text) into the enterprise-denied page. The message is // HTML-escaped before insertion. func renderEnterpriseDeniedHTML(serverMsg string) string { msg := strings.TrimSpace(serverMsg) if msg == "" { msg = defaultEnterpriseDeniedMsg } return strings.ReplaceAll(enterpriseDeniedHTML, "__ENTERPRISE_DENIED_MSG__", html.EscapeString(msg)+" 此页面可以关闭。") } // 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 *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" ChannelConfigEnabled bool `json:"channelConfigEnabled,omitempty"` // whether org has any channel restriction configured } // classifyDenialReason inspects a CLIAuthStatus response and returns a machine-readable // denial reason string. Returns "" when access is granted. // // Priority rationale: // 1. Explicit org-wide ban (userScope=forbidden) always wins. // 2. Channel scope is evaluated BEFORE user scope because the CLI has // authoritative knowledge of DWS_CHANNEL and can verify membership against // allowedChannels. This avoids falsely blaming the user when the real // denial cause is a channel mismatch (e.g. user is in allowedUsers but the // current channel is not in allowedChannels). // 3. Only when the channel is unrestricted or matches do we attribute the // denial to the user scope. func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string { if status.ErrorCode == "CHANNEL_REQUIRED" { return "channel_required" } if status.ErrorCode == "ENTERPRISE_NOT_AUTHORIZED" { return "enterprise_not_authorized" } if status.ErrorCode == "NO_AUTH" { return "no_auth" } if status.Result == nil || !status.Success { return "unknown" } r := status.Result if r.CLIAuthEnabled { return "" } if r.UserScope == "forbidden" { return "user_forbidden" } if r.ChannelScope == "specified" { if currentChannel == "" { return "channel_required" } if !slices.Contains(r.AllowedChannels, currentChannel) { return "channel_not_allowed" } } if r.UserScope == "specified" { return "user_not_allowed" } return "cli_not_enabled" } // 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"` } // mcpRequestMaxRetries is the maximum number of attempts for MCP API calls // (e.g. /cli/cliAuthEnabled, /cli/clientId, /cli/superAdmin, /cli/sendCliAuthApply) // to tolerate transient network errors before propagating the failure. const mcpRequestMaxRetries = 3 // CheckCLIAuthEnabled checks if CLI authorization is enabled for the current corp. // It retries up to mcpRequestMaxRetries times on transient errors to avoid // false negatives caused by momentary network issues. func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) { var lastErr error for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(time.Duration(attempt) * time.Second): } } status, err := p.doCheckCLIAuthEnabled(ctx, accessToken) if err == nil { return status, nil } lastErr = err } return nil, fmt.Errorf("check CLI auth status failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) } func (p *OAuthProvider) doCheckCLIAuthEnabled(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) if ch := os.Getenv("DWS_CHANNEL"); ch != "" { req.Header.Set("x-dws-channel", ch) } applyEditionEnterpriseCredentialHeaders(req) 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. // It retries up to mcpRequestMaxRetries times on transient errors. func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) { var lastErr error for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(time.Duration(attempt) * time.Second): } } result, err := doGetSuperAdmins(ctx, accessToken) if err == nil { return result, nil } lastErr = err } return nil, fmt.Errorf("get super admins failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) } func doGetSuperAdmins(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) applyEditionEnterpriseCredentialHeaders(req) 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. // It retries up to mcpRequestMaxRetries times on transient errors. func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) { var lastErr error for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(time.Duration(attempt) * time.Second): } } result, err := doSendCliAuthApply(ctx, accessToken, adminStaffID) if err == nil { return result, nil } lastErr = err } return nil, fmt.Errorf("send CLI auth apply failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) } func doSendCliAuthApply(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) applyEditionEnterpriseCredentialHeaders(req) 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. // It retries up to mcpRequestMaxRetries times on transient errors. func FetchClientIDFromMCP(ctx context.Context) (string, error) { var lastErr error for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ { if attempt > 0 { select { case <-ctx.Done(): return "", ctx.Err() case <-time.After(time.Duration(attempt) * time.Second): } } id, err := doFetchClientIDFromMCP(ctx) if err == nil { return id, nil } lastErr = err } return "", fmt.Errorf("fetch client ID failed after %d attempts: %w", mcpRequestMaxRetries, lastErr) } func doFetchClientIDFromMCP(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 }