// 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/internal/i18n" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" ) var ( oauthSaveClientSecret = SaveClientSecret oauthSaveTokenLocked = saveTokenDataLocked oauthRetryAfter = time.After oauthNewRequest = http.NewRequestWithContext ) func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) { if err := preflightTokenPersistence(p.configDir); err != nil { return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err) } // 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 := oauthSaveClientSecret(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 == "" || strings.HasPrefix(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 := oauthSaveTokenLocked(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 := oauthSaveTokenLocked(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 := oauthNewRequest(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, readErr := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) if resp.StatusCode != http.StatusOK { // Preserve structured HTTP status semantics even when the response // body is truncated. The body is diagnostic-only here, so read it // best-effort and classify retryability from the status code. if readErr != nil { data = nil } return nil, &HTTPStatusError{ StatusCode: resp.StatusCode, responseBody: truncateBody(data, 200), } } if readErr != nil { return nil, fmt.Errorf("reading response: %w", readErr) } 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"` UserID string `json:"userId"` UserName string `json:"userName"` // 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), UserID: resp.UserID, UserName: resp.UserName, } 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
访问其个人数据」的权限。
您不在该组织的 CLI 授权人员范围内。请联系组织管理员将您加入授权名单。此页面可以关闭。
当前渠道未获得该组织授权,或组织已开启渠道管控。请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI。此页面可以关闭。
__ENTERPRISE_DENIED_MSG__