Files

274 lines
8.8 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 (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const rejectedTokenRefreshFailureCooldown = 3 * time.Second
type rejectedTokenRefreshKey struct {
configDir string
profile string
tokenDigest [sha256.Size]byte
}
type rejectedTokenRefreshCall struct {
done chan struct{}
participants int
token string
err error
}
type rejectedTokenRefreshFailure struct {
at time.Time
err error
}
var rejectedTokenRefreshCoordinator = struct {
sync.Mutex
inFlight map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall
failures map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure
now func() time.Time
}{
inFlight: make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall),
failures: make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure),
now: time.Now,
}
// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past
// instant (preserving access_token and refresh_token), and writes it back. The
// next OAuthProvider.GetAccessToken call will see IsAccessTokenValid() == false
// and proceed to lockedRefresh, exchanging the refresh_token for a fresh
// access_token.
//
// Use this only when the server has rejected the current access_token but the
// local expiry has not yet elapsed (zombie token scenario). It does not delete
// any token material and is safe to call concurrently — actual refresh is
// serialized by lockedRefresh's dual-layer locking.
//
// Returns the original load error when there is no usable token on disk; a
// nil error when there is no access_token to invalidate (no-op).
func MarkAccessTokenStale(configDir string) error {
data, err := LoadTokenData(configDir)
if err != nil {
return err
}
if data == nil || data.AccessToken == "" {
return nil
}
data.ExpiresAt = time.Now().Add(-1 * time.Minute)
return SaveTokenData(configDir, data)
}
// ForceRefreshRejectedToken refreshes rejectedAccessToken only while it is
// still the credential stored for the active profile. The compare and refresh
// run under the same process + file lock used by ordinary expiry refresh, so a
// late rejection cannot invalidate or refresh over a token another caller has
// already rotated.
//
// When the stored token no longer matches, the newer token is returned without
// calling the refresh endpoint. Refresh failures leave the stored credential in
// place; login/logout remain the only owners of credential deletion.
func (p *OAuthProvider) ForceRefreshRejectedToken(ctx context.Context, rejectedAccessToken string) (string, error) {
if p == nil || strings.TrimSpace(p.configDir) == "" {
return "", fmt.Errorf("config directory is empty")
}
rejectedAccessToken = strings.TrimSpace(rejectedAccessToken)
if rejectedAccessToken == "" {
return "", fmt.Errorf("rejected access token is empty")
}
if ctx == nil {
ctx = context.Background()
}
profile := strings.TrimSpace(RuntimeProfile())
key := newRejectedTokenRefreshKey(p.configDir, profile, rejectedAccessToken)
call, leader := beginRejectedTokenRefresh(key)
if !leader {
select {
case <-call.done:
return call.token, call.err
case <-ctx.Done():
return "", ctx.Err()
}
}
token, err, recordFailure := p.forceRefreshRejectedTokenOnce(ctx, profile, rejectedAccessToken, key)
finishRejectedTokenRefresh(key, call, token, err, recordFailure)
return token, err
}
func (p *OAuthProvider) forceRefreshRejectedTokenOnce(
ctx context.Context,
profile string,
rejectedAccessToken string,
key rejectedTokenRefreshKey,
) (string, error, bool) {
lock, err := oauthAcquireLock(ctx, p.configDir)
if err != nil {
return "", fmt.Errorf("acquiring dual lock: %w", err), false
}
defer lock.Release()
data, err := loadOAuthTokenUnderHeldLock(p.configDir, profile)
if err != nil {
return "", fmt.Errorf("reload rejected token: %w", err), false
}
current := strings.TrimSpace(data.AccessToken)
if current == "" {
clearRejectedTokenRefreshFailure(key)
return "", fmt.Errorf("stored access token is empty"), false
}
if current != rejectedAccessToken {
clearRejectedTokenRefreshFailure(key)
return current, nil, false
}
if cachedErr := recentRejectedTokenRefreshFailure(key); cachedErr != nil {
return "", cachedErr, false
}
if !data.IsRefreshTokenValid() {
return "", fmt.Errorf("refresh_token 已过期"), true
}
if err := preflightTokenRefreshPersistence(p.configDir, data); err != nil {
return "", fmt.Errorf("本地登录态无法安全更新: %w", err), true
}
refreshed, err := oauthRefreshToken(p, ctx, data)
if err != nil {
return "", err, true
}
if refreshed == nil || strings.TrimSpace(refreshed.AccessToken) == "" {
return "", fmt.Errorf("force refresh returned empty access token"), true
}
return strings.TrimSpace(refreshed.AccessToken), nil, false
}
func newRejectedTokenRefreshKey(configDir, profile, rejectedAccessToken string) rejectedTokenRefreshKey {
canonicalDir := filepath.Clean(configDir)
if absolute, err := filepath.Abs(configDir); err == nil {
canonicalDir = filepath.Clean(absolute)
}
return rejectedTokenRefreshKey{
configDir: canonicalDir,
profile: strings.TrimSpace(profile),
tokenDigest: sha256.Sum256([]byte(strings.TrimSpace(rejectedAccessToken))),
}
}
func beginRejectedTokenRefresh(key rejectedTokenRefreshKey) (*rejectedTokenRefreshCall, bool) {
rejectedTokenRefreshCoordinator.Lock()
defer rejectedTokenRefreshCoordinator.Unlock()
if call := rejectedTokenRefreshCoordinator.inFlight[key]; call != nil {
call.participants++
return call, false
}
call := &rejectedTokenRefreshCall{done: make(chan struct{}), participants: 1}
rejectedTokenRefreshCoordinator.inFlight[key] = call
return call, true
}
func finishRejectedTokenRefresh(
key rejectedTokenRefreshKey,
call *rejectedTokenRefreshCall,
token string,
err error,
recordFailure bool,
) {
rejectedTokenRefreshCoordinator.Lock()
defer rejectedTokenRefreshCoordinator.Unlock()
call.token = token
call.err = err
delete(rejectedTokenRefreshCoordinator.inFlight, key)
if err == nil {
delete(rejectedTokenRefreshCoordinator.failures, key)
} else if recordFailure && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
now := time.Now()
if rejectedTokenRefreshCoordinator.now != nil {
now = rejectedTokenRefreshCoordinator.now()
}
rejectedTokenRefreshCoordinator.failures[key] = rejectedTokenRefreshFailure{at: now, err: err}
}
close(call.done)
}
func recentRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) error {
rejectedTokenRefreshCoordinator.Lock()
defer rejectedTokenRefreshCoordinator.Unlock()
now := time.Now()
if rejectedTokenRefreshCoordinator.now != nil {
now = rejectedTokenRefreshCoordinator.now()
}
for failureKey, failure := range rejectedTokenRefreshCoordinator.failures {
age := now.Sub(failure.at)
if age < 0 || age >= rejectedTokenRefreshFailureCooldown {
delete(rejectedTokenRefreshCoordinator.failures, failureKey)
}
}
if failure, ok := rejectedTokenRefreshCoordinator.failures[key]; ok {
return failure.err
}
return nil
}
func clearRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) {
rejectedTokenRefreshCoordinator.Lock()
delete(rejectedTokenRefreshCoordinator.failures, key)
rejectedTokenRefreshCoordinator.Unlock()
}
// loadOAuthTokenUnderHeldLock mirrors LoadTokenDataForProfile without taking a
// second, non-reentrant auth lock. Opaque edition storage hooks (for example
// Wukong's encrypted .data file) are read inside the caller's dual lock so the
// compare-and-refresh decision covers both Core and embedded storage.
func loadOAuthTokenUnderHeldLock(configDir, profile string) (*TokenData, error) {
hooks := edition.Get()
if hooks.LoadToken == nil {
data, err := oauthLoadTokenLocked(configDir, profile)
if err != nil {
return nil, err
}
if data == nil {
return nil, fmt.Errorf("stored token data is empty")
}
return data, nil
}
if strings.TrimSpace(profile) != "" {
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
}
blob, err := hooks.LoadToken(configDir)
if err != nil {
return nil, err
}
var data TokenData
if err := json.Unmarshal(blob, &data); err != nil {
return nil, fmt.Errorf("parsing token data from hook: %w", err)
}
return &data, nil
}