Files

103 lines
3.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 (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
)
// RefreshFailureClass separates refresh failures that may recover after a
// delay from failures that require new credentials or local intervention.
type RefreshFailureClass string
const (
RefreshFailureUnknown RefreshFailureClass = "unknown"
RefreshFailureTransient RefreshFailureClass = "transient"
RefreshFailureTerminal RefreshFailureClass = "terminal"
)
// HTTPStatusError preserves an OAuth endpoint status for structured retry
// decisions without copying an untrusted response body into logs.
type HTTPStatusError struct {
StatusCode int
responseBody string
}
func (e *HTTPStatusError) Error() string {
if e == nil {
return "OAuth endpoint request failed"
}
return fmt.Sprintf("HTTP %d", e.StatusCode)
}
func httpStatusResponseBody(err error) string {
var statusErr *HTTPStatusError
if !errors.As(err, &statusErr) || statusErr == nil {
return ""
}
return statusErr.responseBody
}
// ClassifyRefreshFailure uses only structured transport and HTTP signals.
// Unknown errors, including parse, keychain and persistence failures, remain
// fatal so a long-running source cannot retry an error that needs user action.
func ClassifyRefreshFailure(err error) RefreshFailureClass {
if err == nil {
return RefreshFailureUnknown
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return RefreshFailureTransient
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return RefreshFailureTransient
}
var netErr net.Error
if errors.As(err, &netErr) {
return RefreshFailureTransient
}
var statusErr *HTTPStatusError
if !errors.As(err, &statusErr) || statusErr == nil {
return RefreshFailureUnknown
}
if statusErr.StatusCode == http.StatusRequestTimeout ||
statusErr.StatusCode == http.StatusTooManyRequests ||
statusErr.StatusCode >= http.StatusInternalServerError {
return RefreshFailureTransient
}
if statusErr.StatusCode == http.StatusBadRequest ||
statusErr.StatusCode == http.StatusUnauthorized ||
statusErr.StatusCode == http.StatusForbidden {
return RefreshFailureTerminal
}
return RefreshFailureUnknown
}
// isRefreshTokenRejected reports whether the server returned a reviewed
// business code that definitively rejects the presented refresh_token.
// Only the reviewed MCP business code enables the organization-slot
// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no
// reviewed business code and deliberately do not trigger the fallback.
func isRefreshTokenRejected(err error) bool {
if err == nil {
return false
}
var exchangeErr *MCPTokenExchangeError
return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization()
}