305 lines
9.5 KiB
Go
305 lines
9.5 KiB
Go
// 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"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
const (
|
|
// appTokenPrefix is the keychain account prefix for app-level tokens.
|
|
appTokenPrefix = "app-token:"
|
|
|
|
// tokenExpiryBuffer is the buffer time before actual expiry to consider
|
|
// the token as expired (same as user token: 5 minutes).
|
|
tokenExpiryBuffer = 5 * time.Minute
|
|
)
|
|
|
|
// AppTokenData stores app-level access tokens (not user-level OAuth tokens).
|
|
// New-style and legacy API tokens are stored separately because they are not interchangeable.
|
|
type AppTokenData struct {
|
|
// New API token (api.dingtalk.com)
|
|
NewAccessToken string `json:"new_access_token,omitempty"`
|
|
NewExpiresAt time.Time `json:"new_expires_at,omitempty"`
|
|
|
|
// Legacy API token (oapi.dingtalk.com)
|
|
LegacyAccessToken string `json:"legacy_access_token,omitempty"`
|
|
LegacyExpiresAt time.Time `json:"legacy_expires_at,omitempty"`
|
|
|
|
// Associated app credentials
|
|
ClientID string `json:"client_id"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// IsNewTokenValid returns true if the new-style API token has not expired.
|
|
func (d *AppTokenData) IsNewTokenValid() bool {
|
|
if d == nil || d.NewAccessToken == "" {
|
|
return false
|
|
}
|
|
return time.Now().Before(d.NewExpiresAt.Add(-tokenExpiryBuffer))
|
|
}
|
|
|
|
// IsLegacyTokenValid returns true if the legacy API token has not expired.
|
|
func (d *AppTokenData) IsLegacyTokenValid() bool {
|
|
if d == nil || d.LegacyAccessToken == "" {
|
|
return false
|
|
}
|
|
return time.Now().Before(d.LegacyExpiresAt.Add(-tokenExpiryBuffer))
|
|
}
|
|
|
|
// SaveAppTokenData persists AppTokenData to keychain, keyed by clientID.
|
|
func SaveAppTokenData(data *AppTokenData) error {
|
|
if data.ClientID == "" {
|
|
return fmt.Errorf("clientID is required for saving app token data")
|
|
}
|
|
data.UpdatedAt = time.Now()
|
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal app token data: %w", err)
|
|
}
|
|
defer func() {
|
|
for i := range jsonData {
|
|
jsonData[i] = 0
|
|
}
|
|
}()
|
|
|
|
account := appTokenPrefix + data.ClientID
|
|
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
|
|
return fmt.Errorf("save app token to keychain: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LoadAppTokenData loads AppTokenData from keychain for the given clientID.
|
|
// Returns nil, nil if no data exists.
|
|
func LoadAppTokenData(clientID string) (*AppTokenData, error) {
|
|
if clientID == "" {
|
|
return nil, fmt.Errorf("clientID is required for loading app token data")
|
|
}
|
|
account := appTokenPrefix + clientID
|
|
jsonStr, err := keychain.Get(keychain.Service, account)
|
|
if err != nil {
|
|
return nil, nil // Not found is not an error
|
|
}
|
|
if jsonStr == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
var data AppTokenData
|
|
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
|
|
return nil, fmt.Errorf("parse app token data: %w", err)
|
|
}
|
|
return &data, nil
|
|
}
|
|
|
|
// DeleteAppTokenData removes AppTokenData from keychain for the given clientID.
|
|
func DeleteAppTokenData(clientID string) error {
|
|
if clientID == "" {
|
|
return nil
|
|
}
|
|
account := appTokenPrefix + clientID
|
|
return keychain.Remove(keychain.Service, account)
|
|
}
|
|
|
|
// --- Token Fetch Functions ---
|
|
|
|
// FetchNewAPIToken obtains a new-style app access token from:
|
|
//
|
|
// POST https://api.dingtalk.com/v1.0/oauth2/accessToken
|
|
// Body: {"appKey":"X","appSecret":"X"}
|
|
// Response: {"accessToken":"xxx","expireIn":7200}
|
|
func FetchNewAPIToken(ctx context.Context, appKey, appSecret string) (token string, expiresIn int64, err error) {
|
|
body := map[string]string{
|
|
"appKey": appKey,
|
|
"appSecret": appSecret,
|
|
}
|
|
bodyBytes, err := json.Marshal(body)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("marshal request body: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, AppAccessTokenURL, bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := appTokenHTTPClient.Do(req)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("fetching new API token: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("reading response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", 0, fmt.Errorf("获取新版 API token 失败 (HTTP %d): %s", resp.StatusCode, truncateStr(string(respBody), 200))
|
|
}
|
|
|
|
var result struct {
|
|
AccessToken string `json:"accessToken"`
|
|
ExpireIn int64 `json:"expireIn"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return "", 0, fmt.Errorf("parsing new API token response: %w", err)
|
|
}
|
|
if result.AccessToken == "" {
|
|
return "", 0, fmt.Errorf("新版 API token 响应缺少 accessToken 字段")
|
|
}
|
|
if result.ExpireIn <= 0 {
|
|
result.ExpireIn = config.DefaultAccessTokenExpiry
|
|
}
|
|
return result.AccessToken, result.ExpireIn, nil
|
|
}
|
|
|
|
// FetchLegacyAPIToken obtains a legacy app access token from:
|
|
//
|
|
// GET https://oapi.dingtalk.com/gettoken?appkey=X&appsecret=X
|
|
// Response: {"errcode":0,"access_token":"xxx","errmsg":"ok","expires_in":7200}
|
|
func FetchLegacyAPIToken(ctx context.Context, appKey, appSecret string) (token string, expiresIn int64, err error) {
|
|
u := LegacyGetTokenURL + "?appkey=" + appKey + "&appsecret=" + appSecret
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
|
|
resp, err := appTokenHTTPClient.Do(req)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("fetching legacy API token: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("reading response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", 0, fmt.Errorf("获取旧版 API token 失败 (HTTP %d): %s", resp.StatusCode, truncateStr(string(respBody), 200))
|
|
}
|
|
|
|
var result struct {
|
|
ErrCode int `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return "", 0, fmt.Errorf("parsing legacy API token response: %w", err)
|
|
}
|
|
if result.ErrCode != 0 {
|
|
return "", 0, fmt.Errorf("获取旧版 API token 业务错误 (errcode: %d): %s", result.ErrCode, result.ErrMsg)
|
|
}
|
|
if result.AccessToken == "" {
|
|
return "", 0, fmt.Errorf("旧版 API token 响应缺少 access_token 字段")
|
|
}
|
|
if result.ExpiresIn <= 0 {
|
|
result.ExpiresIn = config.DefaultAccessTokenExpiry
|
|
}
|
|
return result.AccessToken, result.ExpiresIn, nil
|
|
}
|
|
|
|
// --- AppTokenProvider ---
|
|
|
|
// AppTokenProvider manages app-level token acquisition, caching and auto-refresh.
|
|
type AppTokenProvider struct {
|
|
ConfigDir string
|
|
AppKey string
|
|
AppSecret string
|
|
HTTPClient *http.Client // injectable for testing; nil uses default
|
|
}
|
|
|
|
// GetToken returns a valid app-level access token for the given API style.
|
|
// isLegacy=true returns a legacy (oapi) token; false returns a new-style (api) token.
|
|
// Tokens are cached in keychain and auto-refreshed when expired (with 5-min buffer).
|
|
func (p *AppTokenProvider) GetToken(ctx context.Context, isLegacy bool) (string, error) {
|
|
if p.AppKey == "" || p.AppSecret == "" {
|
|
return "", fmt.Errorf("缺少应用凭证 (appKey/appSecret),请通过 --client-id/--client-secret 指定或先执行 dws auth login")
|
|
}
|
|
|
|
// Load cached token data.
|
|
data, err := LoadAppTokenData(p.AppKey)
|
|
if err != nil {
|
|
data = nil // Treat load errors as cache miss
|
|
}
|
|
|
|
// Fast path: cached token is still valid.
|
|
if data != nil {
|
|
if isLegacy && data.IsLegacyTokenValid() {
|
|
return data.LegacyAccessToken, nil
|
|
}
|
|
if !isLegacy && data.IsNewTokenValid() {
|
|
return data.NewAccessToken, nil
|
|
}
|
|
}
|
|
|
|
// Slow path: fetch a new token.
|
|
if data == nil {
|
|
data = &AppTokenData{ClientID: p.AppKey}
|
|
}
|
|
|
|
now := time.Now()
|
|
if isLegacy {
|
|
token, expiresIn, fetchErr := FetchLegacyAPIToken(ctx, p.AppKey, p.AppSecret)
|
|
if fetchErr != nil {
|
|
return "", fetchErr
|
|
}
|
|
data.LegacyAccessToken = token
|
|
data.LegacyExpiresAt = now.Add(time.Duration(expiresIn) * time.Second)
|
|
} else {
|
|
token, expiresIn, fetchErr := FetchNewAPIToken(ctx, p.AppKey, p.AppSecret)
|
|
if fetchErr != nil {
|
|
return "", fetchErr
|
|
}
|
|
data.NewAccessToken = token
|
|
data.NewExpiresAt = now.Add(time.Duration(expiresIn) * time.Second)
|
|
}
|
|
|
|
// Persist updated token data.
|
|
if saveErr := SaveAppTokenData(data); saveErr != nil {
|
|
// Log but don't fail — token is still usable this time.
|
|
fmt.Printf("Warning: 无法缓存 app token: %v\n", saveErr)
|
|
}
|
|
|
|
if isLegacy {
|
|
return data.LegacyAccessToken, nil
|
|
}
|
|
return data.NewAccessToken, nil
|
|
}
|
|
|
|
// truncateStr truncates a string to maxLen characters.
|
|
func truncateStr(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen] + "..."
|
|
}
|
|
|
|
// appTokenHTTPClient is the default HTTP client for app token operations.
|
|
var appTokenHTTPClient = &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
}
|