Files
CLIProxyAPI/internal/runtime/executor/claude_executor_auth.go
sususu f63a925d15 fix(claude): replay measured OAuth wire, Fast and diagnostic profiles
Align the remaining measured OAuth wire profiles, including the ordered
connection writer in internal/httpwire that reproduces the observed header
sequence, and the refresh/profile response shapes in internal/auth/claude.

Replay the measured Fast path and keep diagnostic continuity across cloaked and
native requests.

Preserve the native direct token-counting shape so a caller that reaches
count_tokens itself is not reshaped into the cloaked form.

Scope cloak dates to the credential's timezone rather than the host's, so
currentDate matches what the real client would have sent for that account.
2026-08-03 14:47:26 +08:00

129 lines
5.2 KiB
Go

package executor
import (
"context"
"fmt"
"strings"
"time"
claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
const (
claudeAccountProfileCheckedAtKey = "claude_account_profile_checked_at"
claudeAccountProfileRefreshAge = 24 * time.Hour
claudeAccountProfileTimeout = 10 * time.Second
)
type claudeOAuthProfileFetcher func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error)
func (e *ClaudeExecutor) ShouldPrepareRequestAuth(auth *cliproxyauth.Auth) bool {
apiKey, _ := claudeCreds(auth)
if !isClaudeOAuthToken(apiKey) || auth == nil {
return false
}
if !claudeauth.HasCanonicalDeviceIDPool(claudeauth.ReadDeviceIDPool(&auth.Metadata)) {
return true
}
if helps.ClaudeCredentialAccountUUID(auth) != "" {
return false
}
return claudeAccountProfileLookupDue(claudeauth.ReadMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey), time.Now())
}
// claudeAccountProfileLookupDue takes the already-read timestamp rather than the
// metadata map: the map belongs to a credential shared by concurrent requests and
// may only be touched under the metadata lock.
func claudeAccountProfileLookupDue(checkedAt string, now time.Time) bool {
checkedAt = strings.TrimSpace(checkedAt)
if checkedAt == "" {
return true
}
parsed, errParse := time.Parse(time.RFC3339, checkedAt)
return errParse != nil || !parsed.Add(claudeAccountProfileRefreshAge).After(now)
}
func (e *ClaudeExecutor) PrepareRequestAuth(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
if auth == nil || !e.ShouldPrepareRequestAuth(auth) {
return auth, nil
}
apiKey, _ := claudeCreds(auth)
claudeauth.EnsureMetadataMap(&auth.Metadata)
if _, errDeviceIDs := helps.EnsureClaudeCredentialDevicePoolRequired(ctx, auth); errDeviceIDs != nil {
return nil, errDeviceIDs
}
if helps.ClaudeCredentialAccountUUID(auth) != "" ||
!claudeAccountProfileLookupDue(claudeauth.ReadMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey), time.Now()) {
return auth, nil
}
claudeauth.StoreMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey, time.Now().UTC().Format(time.RFC3339))
profile, errProfile := e.fetchClaudeOAuthProfile(ctx, auth, apiKey)
if errProfile != nil {
if errContext := ctx.Err(); errContext != nil {
return nil, errContext
}
log.WithError(errProfile).Warn("claude executor: unable to populate OAuth account profile")
return auth, nil
}
if profile == nil {
return auth, nil
}
claudeauth.StoreMetadataString(&auth.Metadata, "account_uuid", profile.Account.UUID)
claudeauth.StoreMetadataString(&auth.Metadata, "email", profile.Account.Email)
claudeauth.StoreMetadataString(&auth.Metadata, "organization_uuid", profile.Organization.UUID)
claudeauth.StoreMetadataString(&auth.Metadata, "organization_name", profile.Organization.Name)
return auth, nil
}
func (e *ClaudeExecutor) fetchClaudeOAuthProfile(ctx context.Context, auth *cliproxyauth.Auth, apiKey string) (*claudeauth.OAuthProfile, error) {
if e == nil {
return nil, fmt.Errorf("fetch Claude OAuth profile: executor is nil")
}
if e.oauthProfileFetcher != nil {
return e.oauthProfileFetcher(ctx, auth, apiKey)
}
if auth == nil {
return nil, fmt.Errorf("fetch Claude OAuth profile: auth is nil")
}
profileCtx, cancelProfile := context.WithTimeout(ctx, claudeAccountProfileTimeout)
defer cancelProfile()
service := claudeauth.NewClaudeAuthWithProxyURL(e.cfg, auth.ProxyURL)
return service.FetchOAuthProfile(profileCtx, apiKey)
}
func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
log.Debugf("claude executor: refresh called")
if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
return refreshed, err
}
if auth == nil {
return nil, fmt.Errorf("claude executor: auth is nil")
}
refreshToken := claudeauth.ReadMetadataString(&auth.Metadata, "refresh_token")
if refreshToken == "" {
return auth, nil
}
svc := claudeauth.NewClaudeAuthWithProxyURL(e.cfg, auth.ProxyURL)
td, err := svc.RefreshTokensWithRetry(ctx, refreshToken, 3)
if err != nil {
return nil, err
}
claudeauth.EnsureMetadataMap(&auth.Metadata)
claudeauth.StoreMetadataValue(&auth.Metadata, "access_token", td.AccessToken)
claudeauth.StoreMetadataString(&auth.Metadata, "refresh_token", td.RefreshToken)
// Profile fields are optional when token rotation succeeds but the follow-up
// profile lookup fails. Never erase the previously resolved credential identity.
claudeauth.StoreMetadataString(&auth.Metadata, "email", td.Email)
claudeauth.StoreMetadataString(&auth.Metadata, "account_uuid", td.AccountUUID)
claudeauth.StoreMetadataString(&auth.Metadata, "organization_uuid", td.OrganizationUUID)
claudeauth.StoreMetadataString(&auth.Metadata, "organization_name", td.OrganizationName)
claudeauth.StoreMetadataValue(&auth.Metadata, "expired", td.Expire)
claudeauth.StoreMetadataValue(&auth.Metadata, "type", "claude")
claudeauth.StoreMetadataValue(&auth.Metadata, "last_refresh", time.Now().Format(time.RFC3339))
return auth, nil
}