mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
This commit is contained in:
@@ -30,6 +30,48 @@ func (e *ClaudeExecutor) ShouldPrepareRequestAuth(auth *cliproxyauth.Auth) bool
|
||||
return helps.ClaudeCredentialAccountUUID(auth) == ""
|
||||
}
|
||||
|
||||
func isClaudeSetupToken(auth *cliproxyauth.Auth, apiKey string) bool {
|
||||
if !isClaudeOAuthToken(apiKey) || auth == nil {
|
||||
return false
|
||||
}
|
||||
if skip, _ := auth.Metadata["skip_account_profile"].(bool); skip {
|
||||
return true
|
||||
}
|
||||
if isSetup, _ := auth.Metadata["is_setup_token"].(bool); isSetup {
|
||||
return true
|
||||
}
|
||||
if isSetup, _ := auth.Metadata["setup_token"].(bool); isSetup {
|
||||
return true
|
||||
}
|
||||
if kind := strings.ToLower(auth.Attributes["auth_kind"]); kind == "setup_token" || kind == "setup-token" {
|
||||
return true
|
||||
}
|
||||
scopes := strings.ToLower(claudeauth.ReadMetadataString(&auth.Metadata, "scopes"))
|
||||
if scopes == "" {
|
||||
scopes = strings.ToLower(claudeauth.ReadMetadataString(&auth.Metadata, "scope"))
|
||||
}
|
||||
if scopes != "" && !strings.Contains(scopes, "user:profile") && !strings.Contains(scopes, "user:office") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isClaudeOAuthScope403(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "status 403") ||
|
||||
strings.Contains(msg, "403 forbidden") ||
|
||||
strings.Contains(msg, "403") ||
|
||||
strings.Contains(msg, "forbidden") ||
|
||||
strings.Contains(msg, "permission_error") ||
|
||||
strings.Contains(msg, "scope requirement") ||
|
||||
strings.Contains(msg, "insufficient_scope") ||
|
||||
strings.Contains(msg, "user:profile") ||
|
||||
strings.Contains(msg, "user:office")
|
||||
}
|
||||
|
||||
func (e *ClaudeExecutor) PrepareRequestAuth(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
||||
if auth == nil || !e.ShouldPrepareRequestAuth(auth) {
|
||||
return auth, nil
|
||||
@@ -43,15 +85,42 @@ func (e *ClaudeExecutor) PrepareRequestAuth(ctx context.Context, auth *cliproxya
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
if isClaudeSetupToken(auth, apiKey) {
|
||||
seed := helps.ClaudeCLIAuthIdentitySeed(auth)
|
||||
if seed == "" {
|
||||
seed = "claude-setup-token|" + apiKey
|
||||
}
|
||||
claudeauth.StoreMetadataString(&auth.Metadata, "account_uuid", helps.StableClaudeCLIAccountUUID(seed))
|
||||
claudeauth.StoreMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey, time.Now().UTC().Format(time.RFC3339))
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
profile, errProfile := e.fetchClaudeOAuthProfile(ctx, auth, apiKey)
|
||||
if errProfile != nil {
|
||||
if errContext := ctx.Err(); errContext != nil {
|
||||
return nil, errContext
|
||||
}
|
||||
if isClaudeOAuthScope403(errProfile) {
|
||||
log.Debugf("Claude OAuth account profile lookup returned 403 for auth %s: %v (falling back to stable credential identity)", auth.ID, errProfile)
|
||||
seed := helps.ClaudeCLIAuthIdentitySeed(auth)
|
||||
if seed == "" {
|
||||
seed = "claude-oauth-fallback|" + apiKey
|
||||
}
|
||||
claudeauth.StoreMetadataString(&auth.Metadata, "account_uuid", helps.StableClaudeCLIAccountUUID(seed))
|
||||
claudeauth.StoreMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey, time.Now().UTC().Format(time.RFC3339))
|
||||
return auth, nil
|
||||
}
|
||||
return nil, fmt.Errorf("populate Claude OAuth account profile: %w", errProfile)
|
||||
}
|
||||
if profile == nil || strings.TrimSpace(profile.Account.UUID) == "" {
|
||||
return nil, fmt.Errorf("populate Claude OAuth account profile: account UUID is empty")
|
||||
log.Debugf("Claude OAuth account profile lookup returned empty account UUID for auth %s (falling back to stable credential identity)", auth.ID)
|
||||
seed := helps.ClaudeCLIAuthIdentitySeed(auth)
|
||||
if seed == "" {
|
||||
seed = "claude-oauth-fallback|" + apiKey
|
||||
}
|
||||
claudeauth.StoreMetadataString(&auth.Metadata, "account_uuid", helps.StableClaudeCLIAccountUUID(seed))
|
||||
claudeauth.StoreMetadataString(&auth.Metadata, claudeAccountProfileCheckedAtKey, time.Now().UTC().Format(time.RFC3339))
|
||||
return auth, nil
|
||||
}
|
||||
claudeauth.StoreMetadataString(&auth.Metadata, "account_uuid", profile.Account.UUID)
|
||||
claudeauth.StoreMetadataString(&auth.Metadata, "email", profile.Account.Email)
|
||||
@@ -86,6 +155,9 @@ func (e *ClaudeExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (
|
||||
return nil, fmt.Errorf("claude executor: auth is nil")
|
||||
}
|
||||
refreshToken := claudeauth.ReadMetadataString(&auth.Metadata, "refresh_token")
|
||||
if refreshToken == "" {
|
||||
refreshToken = claudeauth.ReadMetadataString(&auth.Metadata, "refreshToken")
|
||||
}
|
||||
if refreshToken == "" {
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
@@ -192,3 +192,155 @@ func TestClaudeExecutorPrepareRequestAuthIgnoresFreshTimestampWithoutIdentity(t
|
||||
t.Fatalf("profile checked timestamp = %q, want prior value preserved without suppressing retry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeExecutorPrepareRequestAuthSetupTokenBypassesProfile(t *testing.T) {
|
||||
executor := NewClaudeExecutor(&config.Config{})
|
||||
executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) {
|
||||
t.Fatal("profile fetcher should NOT be called for setup-tokens")
|
||||
return nil, nil
|
||||
}
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "claude-setuptoken.json",
|
||||
Attributes: map[string]string{
|
||||
"api_key": "sk-ant-oat01-test-setup-token-value",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"type": "claude",
|
||||
"scopes": "user:inference user:ccr_inference user:file_upload",
|
||||
},
|
||||
}
|
||||
|
||||
if !executor.ShouldPrepareRequestAuth(auth) {
|
||||
t.Fatal("ShouldPrepareRequestAuth() = false for missing setup-token identity")
|
||||
}
|
||||
prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth)
|
||||
if errPrepare != nil {
|
||||
t.Fatalf("PrepareRequestAuth() error = %v", errPrepare)
|
||||
}
|
||||
if prepared == nil {
|
||||
t.Fatal("prepared auth is nil")
|
||||
}
|
||||
accountUUID := claudeauth.ReadMetadataString(&prepared.Metadata, "account_uuid")
|
||||
if accountUUID == "" {
|
||||
t.Fatal("account_uuid is empty after setup-token preparation")
|
||||
}
|
||||
deviceIDs := claudeauth.NormalizeDeviceIDPool(prepared.Metadata[claudeauth.ClaudeDeviceIDsMetadataKey])
|
||||
if len(deviceIDs) != 1 {
|
||||
t.Fatalf("device pool length = %d, want 1", len(deviceIDs))
|
||||
}
|
||||
if executor.ShouldPrepareRequestAuth(prepared) {
|
||||
t.Fatal("ShouldPrepareRequestAuth() = true after setup-token identity was populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeExecutorPrepareRequestAuth403ScopeFallback(t *testing.T) {
|
||||
executor := NewClaudeExecutor(&config.Config{})
|
||||
fetchCalls := 0
|
||||
executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) {
|
||||
fetchCalls++
|
||||
return nil, fmt.Errorf("fetch Claude OAuth profile failed with status 403: permission_error: OAuth token does not meet scope requirement any_of(user:profile, user:office)")
|
||||
}
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "claude-scope-restricted-credential",
|
||||
Attributes: map[string]string{
|
||||
"api_key": "sk-ant-oat01-scope-restricted",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"type": "claude",
|
||||
"refresh_token": "dummy-refresh-token",
|
||||
},
|
||||
}
|
||||
|
||||
if !executor.ShouldPrepareRequestAuth(auth) {
|
||||
t.Fatal("ShouldPrepareRequestAuth() = false for missing identity")
|
||||
}
|
||||
prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth)
|
||||
if errPrepare != nil {
|
||||
t.Fatalf("PrepareRequestAuth() with 403 error = %v, want fallback success", errPrepare)
|
||||
}
|
||||
if prepared == nil {
|
||||
t.Fatal("prepared auth is nil")
|
||||
}
|
||||
if fetchCalls != 1 {
|
||||
t.Fatalf("fetchCalls = %d, want 1", fetchCalls)
|
||||
}
|
||||
accountUUID := claudeauth.ReadMetadataString(&prepared.Metadata, "account_uuid")
|
||||
if accountUUID == "" {
|
||||
t.Fatal("account_uuid is empty after 403 fallback")
|
||||
}
|
||||
if executor.ShouldPrepareRequestAuth(prepared) {
|
||||
t.Fatal("ShouldPrepareRequestAuth() = true after 403 identity was populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeExecutorPrepareRequestAuthSkipAccountProfileConfig(t *testing.T) {
|
||||
executor := NewClaudeExecutor(&config.Config{})
|
||||
executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) {
|
||||
t.Fatal("profile fetcher should NOT be called when skip_account_profile is true")
|
||||
return nil, nil
|
||||
}
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "claude-skip-profile.json",
|
||||
Attributes: map[string]string{
|
||||
"api_key": "sk-ant-oat01-skip-profile",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"type": "claude",
|
||||
"skip_account_profile": true,
|
||||
},
|
||||
}
|
||||
|
||||
if !executor.ShouldPrepareRequestAuth(auth) {
|
||||
t.Fatal("ShouldPrepareRequestAuth() = false for missing identity")
|
||||
}
|
||||
prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth)
|
||||
if errPrepare != nil {
|
||||
t.Fatalf("PrepareRequestAuth() error = %v", errPrepare)
|
||||
}
|
||||
if prepared == nil {
|
||||
t.Fatal("prepared auth is nil")
|
||||
}
|
||||
accountUUID := claudeauth.ReadMetadataString(&prepared.Metadata, "account_uuid")
|
||||
if accountUUID == "" {
|
||||
t.Fatal("account_uuid is empty after skip_account_profile preparation")
|
||||
}
|
||||
if executor.ShouldPrepareRequestAuth(prepared) {
|
||||
t.Fatal("ShouldPrepareRequestAuth() = true after identity was populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeExecutorPrepareRequestAuthEmptyAccountUUIDInProfileFallback(t *testing.T) {
|
||||
executor := NewClaudeExecutor(&config.Config{})
|
||||
fetchCalls := 0
|
||||
executor.oauthProfileFetcher = func(context.Context, *cliproxyauth.Auth, string) (*claudeauth.OAuthProfile, error) {
|
||||
fetchCalls++
|
||||
return &claudeauth.OAuthProfile{}, nil
|
||||
}
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "claude-empty-uuid-in-profile",
|
||||
Attributes: map[string]string{
|
||||
"api_key": "sk-ant-oat01-empty-uuid",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"type": "claude",
|
||||
},
|
||||
}
|
||||
|
||||
prepared, errPrepare := executor.PrepareRequestAuth(context.Background(), auth)
|
||||
if errPrepare != nil {
|
||||
t.Fatalf("PrepareRequestAuth() error = %v, want fallback on empty UUID", errPrepare)
|
||||
}
|
||||
if prepared == nil {
|
||||
t.Fatal("prepared auth is nil")
|
||||
}
|
||||
if fetchCalls != 1 {
|
||||
t.Fatalf("fetchCalls = %d, want 1", fetchCalls)
|
||||
}
|
||||
accountUUID := claudeauth.ReadMetadataString(&prepared.Metadata, "account_uuid")
|
||||
if accountUUID == "" {
|
||||
t.Fatal("account_uuid is empty after fallback")
|
||||
}
|
||||
if executor.ShouldPrepareRequestAuth(prepared) {
|
||||
t.Fatal("ShouldPrepareRequestAuth() = true after identity was populated")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,20 @@ func stableClaudeCLIDeviceID(seed string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// StableClaudeCLIDeviceID returns a deterministic device ID derived from a seed.
|
||||
func StableClaudeCLIDeviceID(seed string) string {
|
||||
return stableClaudeCLIDeviceID(seed)
|
||||
}
|
||||
|
||||
func stableClaudeCLIAccountUUID(seed string) string {
|
||||
return uuid.NewSHA1(claudeCLIIdentityNamespace, []byte("cpa-claude-code-cli-account|"+seed)).String()
|
||||
}
|
||||
|
||||
// StableClaudeCLIAccountUUID returns a deterministic UUIDv5 account ID derived from a seed.
|
||||
func StableClaudeCLIAccountUUID(seed string) string {
|
||||
return stableClaudeCLIAccountUUID(seed)
|
||||
}
|
||||
|
||||
// ClaudeCLIAuthIdentitySeed returns a stable credential identity that does not
|
||||
// rotate with delegated-provider access tokens.
|
||||
func ClaudeCLIAuthIdentitySeed(auth *cliproxyauth.Auth) string {
|
||||
|
||||
Reference in New Issue
Block a user