fix(claude): support setup-tokens and gracefully handle 403 OAuth profile errors (#4983) (#5083)

This commit is contained in:
sususu98
2026-08-20 00:15:31 +08:00
committed by GitHub
parent 788e9b7928
commit 8aa6868d0d
3 changed files with 235 additions and 1 deletions

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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 {