// 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
授权成功
请返回终端继续操作。此页面可以关闭。
`
const notEnabledHTML = `
钉钉 CLI
该组织尚未开启 CLI 数据访问权限
你所选择的组织管理员尚未开启
「允许成员通过 CLI
访问其个人数据」的权限。
已向管理员发送权限申请,正在等待审核
审核通过后,请返回终端继续操作
返回选择其他组织
`
const accessDeniedHTML = `
钉钉 CLI
无权限访问
您不在该组织的 CLI 授权人员范围内。请联系组织管理员将您加入授权名单。此页面可以关闭。
`
const channelDeniedHTML = `
钉钉 CLI
渠道未授权
当前渠道未获得该组织授权,或组织已开启渠道管控。请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI。此页面可以关闭。
`
const enterpriseDeniedHTML = `
钉钉 CLI
企业安全认证未通过
__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
}