173 lines
5.6 KiB
Go
173 lines
5.6 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 app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
|
|
)
|
|
|
|
// authRetryingKey marks a context that has already attempted one
|
|
// AuthRefreshRequired-driven retry of the current invocation. The runner uses
|
|
// this to refuse a second refresh+retry pass and surface the original cause
|
|
// to the user instead.
|
|
type authRetryingKeyType struct{}
|
|
|
|
type authRefreshFailureError struct {
|
|
rejection error
|
|
refresh error
|
|
}
|
|
|
|
func (e *authRefreshFailureError) Error() string {
|
|
return "automatic access token refresh failed"
|
|
}
|
|
|
|
func (e *authRefreshFailureError) Unwrap() []error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return []error{e.rejection, e.refresh}
|
|
}
|
|
|
|
var authRetryingKey = authRetryingKeyType{}
|
|
|
|
var (
|
|
runnerForceRefreshRejectedAccessToken = forceRefreshRejectedAccessToken
|
|
runnerExecuteAuthRetry func(*runtimeRunner, context.Context, string, executor.Invocation) (executor.Result, error)
|
|
)
|
|
|
|
func init() {
|
|
runnerExecuteAuthRetry = func(r *runtimeRunner, ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
|
|
return r.executeInvocation(ctx, endpoint, invocation)
|
|
}
|
|
}
|
|
|
|
// IsAuthRetrying reports whether the current context is already inside an
|
|
// AuthRefreshRequired retry. Mirrors IsPatRetrying.
|
|
func IsAuthRetrying(ctx context.Context) bool {
|
|
if ctx == nil {
|
|
return false
|
|
}
|
|
v, _ := ctx.Value(authRetryingKey).(bool)
|
|
return v
|
|
}
|
|
|
|
func withAuthRetrying(ctx context.Context) context.Context {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
return context.WithValue(ctx, authRetryingKey, true)
|
|
}
|
|
|
|
func authRefreshLogger() *slog.Logger {
|
|
if logger := FileLoggerInstance(); logger != nil {
|
|
return logger
|
|
}
|
|
return slog.Default()
|
|
}
|
|
|
|
func (r *runtimeRunner) managesRuntimeOAuth(hasPluginAuth bool) bool {
|
|
if r == nil || hasPluginAuth {
|
|
return false
|
|
}
|
|
return r.globalFlags == nil || strings.TrimSpace(r.globalFlags.Token) == ""
|
|
}
|
|
|
|
// retryAuthRefreshRequired consumes only the explicit edition marker. It does
|
|
// not infer retryability from free text, generic auth categories, HTTP 403, or
|
|
// ordinary business errors.
|
|
func (r *runtimeRunner) retryAuthRefreshRequired(
|
|
ctx context.Context,
|
|
endpoint string,
|
|
invocation executor.Invocation,
|
|
rejectedAccessToken string,
|
|
markerErr error,
|
|
hasPluginAuth bool,
|
|
) (executor.Result, error, bool) {
|
|
marker, marked := authretry.As(markerErr)
|
|
if !marked {
|
|
return executor.Result{}, nil, false
|
|
}
|
|
cause := marker.Cause
|
|
if cause == nil {
|
|
cause = markerErr
|
|
}
|
|
|
|
// Explicit --token and plugin credentials are not backed by the default
|
|
// OAuth refresh store. Preserve the overlay cause without mutating an
|
|
// unrelated persisted login.
|
|
if !r.managesRuntimeOAuth(hasPluginAuth) {
|
|
return executor.Result{}, cause, true
|
|
}
|
|
if IsAuthRetrying(ctx) {
|
|
authRefreshLogger().Warn("auth.runtime.refresh.retry_exhausted",
|
|
"product", invocation.CanonicalProduct,
|
|
"tool", invocation.Tool,
|
|
)
|
|
return executor.Result{}, cause, true
|
|
}
|
|
|
|
if _, err := runnerForceRefreshRejectedAccessToken(ctx, defaultConfigDir(), rejectedAccessToken); err != nil {
|
|
// Keep every log credential-safe. The returned error chain retains the
|
|
// complete cause for in-process diagnosis; even DWS_DEBUG_AUTH must not
|
|
// serialize an OAuth response body or other attacker-controlled text.
|
|
authRefreshLogger().Warn("auth.runtime.refresh.failed",
|
|
"product", invocation.CanonicalProduct,
|
|
"tool", invocation.Tool,
|
|
"stage", "force_refresh_rejected_token",
|
|
"error_type", fmt.Sprintf("%T", err),
|
|
)
|
|
logging.AuthDebug("auth.runtime.refresh.failed.detail",
|
|
"product", invocation.CanonicalProduct,
|
|
"tool", invocation.Tool,
|
|
"stage", "force_refresh_rejected_token",
|
|
"error_type", fmt.Sprintf("%T", err),
|
|
)
|
|
combined := &authRefreshFailureError{rejection: cause, refresh: err}
|
|
return executor.Result{}, apperrors.NewAuth(
|
|
"automatic access token refresh failed",
|
|
apperrors.WithOperation("auth/token/refresh"),
|
|
apperrors.WithReason("auth_refresh_failed"),
|
|
apperrors.WithHint("本地凭证已保留;可稍后重试,若持续失败请查看认证诊断日志。"),
|
|
apperrors.WithCause(combined),
|
|
), true
|
|
}
|
|
|
|
logging.AuthDebug("auth.runtime.refresh.succeeded",
|
|
"product", invocation.CanonicalProduct,
|
|
"tool", invocation.Tool,
|
|
)
|
|
result, err := runnerExecuteAuthRetry(r, withAuthRetrying(ctx), endpoint, invocation)
|
|
return result, err, true
|
|
}
|
|
|
|
// isRefreshableTransportAuthError deliberately excludes HTTP/RPC 403 and
|
|
// generic CategoryAuth values. OnAuthError may request a refresh only for an
|
|
// exact transport-level unauthorized signal.
|
|
func isRefreshableTransportAuthError(err error) bool {
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth {
|
|
return false
|
|
}
|
|
return typed.Reason == "http_401" || typed.RPCCode == 401
|
|
}
|