Files
CLIProxyAPI/internal/runtime/executor/antigravity_executor_auth.go
Luis Pater 7070785140 fix(auth): preserve upstream status codes in antigravity auth errors
- Introduce `HTTPStatusError` to retain upstream HTTP status codes across Antigravity OAuth and project lookup calls.
- Propagate status codes and retry-after metadata from cause errors in `missingAntigravityProjectIDError`.

Closes: #5368
2026-09-01 08:37:02 +08:00

296 lines
9.0 KiB
Go

package executor
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
// Refresh refreshes the authentication credentials using the refresh token.
func (e *AntigravityExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
return refreshed, err
}
if auth == nil {
return auth, nil
}
updated, errRefresh := e.refreshToken(ctx, auth.Clone())
if errRefresh != nil {
return nil, errRefresh
}
return updated, nil
}
func (e *AntigravityExecutor) ShouldPrepareRequestAuth(auth *cliproxyauth.Auth) bool {
return antigravityProjectIDFromAuth(auth) == ""
}
func (e *AntigravityExecutor) PrepareRequestAuth(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
if auth == nil || !e.ShouldPrepareRequestAuth(auth) {
return nil, nil
}
updated := auth.Clone()
token, refreshedAuth, errToken := e.ensureAccessToken(ctx, updated)
if errToken != nil {
return nil, errToken
}
if refreshedAuth != nil {
updated = refreshedAuth
}
if antigravityProjectIDFromAuth(updated) != "" {
return updated, nil
}
projectID, errProject := e.fetchAntigravityProjectID(ctx, updated, token)
if errProject != nil {
return nil, missingAntigravityProjectIDError(errProject)
}
if projectID == "" {
return nil, missingAntigravityProjectIDError(nil)
}
if updated.Metadata == nil {
updated.Metadata = make(map[string]any)
}
updated.Metadata["project_id"] = projectID
return updated, nil
}
func (e *AntigravityExecutor) ensureAccessToken(ctx context.Context, auth *cliproxyauth.Auth) (string, *cliproxyauth.Auth, error) {
if auth == nil {
return "", nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
}
accessToken := metaStringValue(auth.Metadata, "access_token")
expiry, _ := auth.ExpirationTime()
if accessToken != "" && expiry.After(time.Now().Add(antigravityRequestTokenSafetyWindow)) {
e.maybeRefreshAntigravityCreditsHint(ctx, auth, accessToken)
return accessToken, nil, nil
}
refreshCtx := context.Background()
if ctx != nil {
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
refreshCtx = context.WithValue(refreshCtx, "cliproxy.roundtripper", rt)
}
}
if refreshed, handled, err := helps.RefreshAuthViaHome(refreshCtx, e.cfg, auth); handled {
if err != nil {
return "", nil, err
}
token := metaStringValue(refreshed.Metadata, "access_token")
if strings.TrimSpace(token) == "" {
return "", nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
}
e.maybeRefreshAntigravityCreditsHint(ctx, refreshed, token)
return token, refreshed, nil
}
updated, errRefresh := e.refreshToken(refreshCtx, auth.Clone())
if errRefresh != nil {
return "", nil, errRefresh
}
return metaStringValue(updated.Metadata, "access_token"), updated, nil
}
func (e *AntigravityExecutor) refreshToken(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
if auth == nil {
return nil, statusErr{code: http.StatusUnauthorized, msg: "missing auth"}
}
refreshToken := metaStringValue(auth.Metadata, "refresh_token")
if refreshToken == "" {
return auth, statusErr{code: http.StatusUnauthorized, msg: "missing refresh token"}
}
if ctx == nil {
ctx = context.Background()
}
refreshToken = strings.TrimSpace(refreshToken)
result, errRefresh, _ := antigravityRefreshGroup.Do(refreshToken, func() (interface{}, error) {
return e.refreshTokenSingleFlight(context.WithoutCancel(ctx), auth, refreshToken)
})
if errRefresh != nil {
return auth, errRefresh
}
tokenResp, ok := result.(*antigravityTokenRefreshData)
if !ok || tokenResp == nil {
return auth, fmt.Errorf("antigravity token refresh failed: invalid single-flight result")
}
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
auth.Metadata["access_token"] = tokenResp.AccessToken
if tokenResp.RefreshToken != "" {
auth.Metadata["refresh_token"] = tokenResp.RefreshToken
}
auth.Metadata["expires_in"] = tokenResp.ExpiresIn
now := time.Now()
auth.Metadata["timestamp"] = now.UnixMilli()
auth.Metadata["expired"] = now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339)
auth.Metadata["type"] = antigravityAuthType
if errProject := e.ensureAntigravityProjectID(ctx, auth, tokenResp.AccessToken); errProject != nil {
log.Warnf("antigravity executor: ensure project id failed: %v", errProject)
}
e.updateAntigravityCreditsBalance(ctx, auth, tokenResp.AccessToken)
return auth, nil
}
func (e *AntigravityExecutor) refreshTokenSingleFlight(ctx context.Context, auth *cliproxyauth.Auth, refreshToken string) (*antigravityTokenRefreshData, error) {
form := url.Values{}
form.Set("client_id", antigravityClientID)
form.Set("client_secret", antigravityClientSecret)
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", refreshToken)
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, "https://oauth2.googleapis.com/token", strings.NewReader(form.Encode()))
if errReq != nil {
return nil, errReq
}
httpReq.Header.Set("Host", "oauth2.googleapis.com")
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Real Antigravity uses Go's default User-Agent for OAuth token refresh
httpReq.Header.Set("User-Agent", "Go-http-client/2.0")
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
httpResp, errDo := httpClient.Do(httpReq)
if errDo != nil {
return nil, errDo
}
defer func() {
if errClose := httpResp.Body.Close(); errClose != nil {
log.Errorf("antigravity executor: close response body error: %v", errClose)
}
}()
bodyBytes, errRead := io.ReadAll(httpResp.Body)
if errRead != nil {
return nil, errRead
}
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
sErr := statusErr{code: httpResp.StatusCode, msg: string(bodyBytes)}
if httpResp.StatusCode == http.StatusTooManyRequests {
if retryAfter, parseErr := helps.ParseRetryDelay(bodyBytes); parseErr == nil && retryAfter != nil {
sErr.retryAfter = retryAfter
}
}
return nil, sErr
}
var tokenResp antigravityTokenRefreshData
if errUnmarshal := json.Unmarshal(bodyBytes, &tokenResp); errUnmarshal != nil {
return nil, errUnmarshal
}
return &tokenResp, nil
}
func (e *AntigravityExecutor) ensureAntigravityProjectID(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) error {
if auth == nil {
return nil
}
if antigravityProjectIDFromAuth(auth) != "" {
return nil
}
projectID, errFetch := e.fetchAntigravityProjectID(ctx, auth, accessToken)
if errFetch != nil {
return errFetch
}
if projectID == "" {
return nil
}
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
auth.Metadata["project_id"] = projectID
return nil
}
func (e *AntigravityExecutor) fetchAntigravityProjectID(ctx context.Context, auth *cliproxyauth.Auth, accessToken string) (string, error) {
token := strings.TrimSpace(accessToken)
if token == "" {
token = metaStringValue(auth.Metadata, "access_token")
}
if token == "" {
return "", nil
}
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
projectID, errFetch := sdkAuth.FetchAntigravityProjectID(ctx, token, httpClient)
if errFetch != nil {
return "", errFetch
}
return strings.TrimSpace(projectID), nil
}
func (e *AntigravityExecutor) projectIDForRequest(_ context.Context, auth *cliproxyauth.Auth, _ string) (string, error) {
if projectID := antigravityProjectIDFromAuth(auth); projectID != "" {
return projectID, nil
}
return "", missingAntigravityProjectIDError(nil)
}
func antigravityProjectIDFromAuth(auth *cliproxyauth.Auth) string {
if auth == nil || auth.Metadata == nil {
return ""
}
if pid, ok := auth.Metadata["project_id"].(string); ok {
return strings.TrimSpace(pid)
}
return ""
}
func missingAntigravityProjectIDError(cause error) statusErr {
msg := "antigravity auth missing project_id"
statusCode := http.StatusBadRequest
var retryAfter *time.Duration
if cause != nil {
msg = fmt.Sprintf("%s: %v", msg, cause)
type statusCoder interface {
StatusCode() int
}
var sc statusCoder
if errors.As(cause, &sc) && sc != nil {
if code := sc.StatusCode(); code > 0 {
statusCode = code
}
}
type retryAfterProvider interface {
RetryAfter() *time.Duration
}
var rap retryAfterProvider
if errors.As(cause, &rap) && rap != nil {
retryAfter = rap.RetryAfter()
}
}
return statusErr{code: statusCode, msg: msg, retryAfter: retryAfter}
}
func metaStringValue(metadata map[string]any, key string) string {
if metadata == nil {
return ""
}
if v, ok := metadata[key]; ok {
switch typed := v.(type) {
case string:
return strings.TrimSpace(typed)
case []byte:
return strings.TrimSpace(string(typed))
}
}
return ""
}