diff --git a/internal/runtime/executor/claude_executor_auth.go b/internal/runtime/executor/claude_executor_auth.go index 7a1dd9f94..0bc2a8972 100644 --- a/internal/runtime/executor/claude_executor_auth.go +++ b/internal/runtime/executor/claude_executor_auth.go @@ -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 } diff --git a/internal/runtime/executor/claude_executor_auth_test.go b/internal/runtime/executor/claude_executor_auth_test.go index 433cea900..7d0c87cee 100644 --- a/internal/runtime/executor/claude_executor_auth_test.go +++ b/internal/runtime/executor/claude_executor_auth_test.go @@ -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") + } +} diff --git a/internal/runtime/executor/helps/claude_cli_identity_seed.go b/internal/runtime/executor/helps/claude_cli_identity_seed.go index e166afea0..915fe7005 100644 --- a/internal/runtime/executor/helps/claude_cli_identity_seed.go +++ b/internal/runtime/executor/helps/claude_cli_identity_seed.go @@ -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 {