// 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" "io" "net/http" "net/url" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config" ) func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) { 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 } return p.parseTokenResponse(resp) } func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *TokenData) (*TokenData, error) { 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 } 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 { 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") 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 } func buildAuthURL(clientID, redirectURI string) string { params := url.Values{ "client_id": {clientID}, "redirect_uri": {redirectURI}, "response_type": {"code"}, "scope": {DefaultScopes}, "prompt": {"consent"}, } return AuthorizeURL + "?" + params.Encode() } const successHTML = `
请返回终端继续操作。此页面可以关闭。