mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-06 16:15:50 +08:00
A single *Auth is shared by every concurrent request that selects the same credential, so any path reaching into Auth.Metadata directly races the others. The credential identity helpers initialized and wrote the map outside claudeDevicePoolMu; only EnsureDeviceIDPool took the lock, so a lazy `auth.Metadata = make(...)` racing a pool write could abort the whole process with "concurrent map writes" instead of failing a request. Locking only the device-pool helpers was not enough: the account-profile and refresh paths kept mutating the same map unguarded, which a concurrency probe surfaced as data races. Widen the lock to the whole metadata map and route the remaining call sites through new accessors in internal/auth/claude, including the lazy map initialization, which needs a pointer to the field to stay inside the critical section. claudeAccountProfileLookupDue now takes the already-read timestamp so it cannot be handed an unsynchronized map.
129 lines
5.2 KiB
Go
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)
|
|
// email is written unconditionally to preserve the previous reset-on-refresh
|
|
// behaviour; the remaining optional fields keep their prior value when absent.
|
|
claudeauth.StoreMetadataValue(&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
|
|
}
|