243 lines
7.3 KiB
Go
243 lines
7.3 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"
|
|
"os"
|
|
"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
|
|
)
|
|
|
|
var (
|
|
appTokenMarshalIndent = json.MarshalIndent
|
|
appTokenMarshal = json.Marshal
|
|
appTokenNewRequest = http.NewRequestWithContext
|
|
appTokenKeychainSet = keychain.Set
|
|
appTokenKeychainGet = keychain.Get
|
|
appTokenKeychainRemove = keychain.Remove
|
|
)
|
|
|
|
// AppTokenData stores the app-level access token obtained from the unified
|
|
// POST /v1.0/oauth2/accessToken endpoint. It works for both new-style
|
|
// (api.dingtalk.com) and legacy (oapi.dingtalk.com) APIs — the auth method
|
|
// (header vs query param) is chosen by the caller based on the target host.
|
|
type AppTokenData struct {
|
|
AccessToken string `json:"access_token,omitempty"`
|
|
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
|
|
|
// Associated app credentials
|
|
ClientID string `json:"client_id"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// IsTokenValid returns true if the access token has not expired.
|
|
func (d *AppTokenData) IsTokenValid() bool {
|
|
if d == nil || d.AccessToken == "" {
|
|
return false
|
|
}
|
|
return time.Now().Before(d.ExpiresAt.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 := appTokenMarshalIndent(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 := appTokenKeychainSet(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 := appTokenKeychainGet(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 appTokenKeychainRemove(keychain.Service, account)
|
|
}
|
|
|
|
// --- Token Fetch Function ---
|
|
|
|
// FetchAppToken obtains an app-level access token from the unified endpoint:
|
|
//
|
|
// POST https://api.dingtalk.com/v1.0/oauth2/accessToken
|
|
// Body: {"appKey":"X","appSecret":"X"}
|
|
// Response: {"accessToken":"xxx","expireIn":7200}
|
|
//
|
|
// The same token works for both api.dingtalk.com and oapi.dingtalk.com.
|
|
func FetchAppToken(ctx context.Context, appKey, appSecret string) (token string, expiresIn int64, err error) {
|
|
body := map[string]string{
|
|
"appKey": appKey,
|
|
"appSecret": appSecret,
|
|
}
|
|
bodyBytes, err := appTokenMarshal(body)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("marshal request body: %w", err)
|
|
}
|
|
|
|
req, err := appTokenNewRequest(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 app 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("获取 app 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 app token response: %w", err)
|
|
}
|
|
if result.AccessToken == "" {
|
|
return "", 0, fmt.Errorf("app token 响应缺少 accessToken 字段")
|
|
}
|
|
if result.ExpireIn <= 0 {
|
|
result.ExpireIn = config.DefaultAccessTokenExpiry
|
|
}
|
|
return result.AccessToken, result.ExpireIn, 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.
|
|
// Tokens are cached in keychain and auto-refreshed when expired (with 5-min buffer).
|
|
func (p *AppTokenProvider) GetToken(ctx context.Context) (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 && data.IsTokenValid() {
|
|
return data.AccessToken, nil
|
|
}
|
|
|
|
// Slow path: fetch a new token.
|
|
if data == nil {
|
|
data = &AppTokenData{ClientID: p.AppKey}
|
|
}
|
|
|
|
now := time.Now()
|
|
token, expiresIn, fetchErr := FetchAppToken(ctx, p.AppKey, p.AppSecret)
|
|
if fetchErr != nil {
|
|
return "", fetchErr
|
|
}
|
|
data.AccessToken = token
|
|
data.ExpiresAt = 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.
|
|
// Write to stderr so we don't corrupt stdout JSON output when piped
|
|
// into jq/grep/etc.
|
|
fmt.Fprintf(os.Stderr, "Warning: 无法缓存 app token: %v\n", saveErr)
|
|
}
|
|
|
|
return data.AccessToken, 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,
|
|
}
|