github.com/anthropics/anthropic-sdk-go@v1.70.1/internal/auth/integration_test.go (about) 1 package auth_test 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "log" 8 "net/http" 9 "net/http/httptest" 10 "os" 11 "path/filepath" 12 "strings" 13 "sync" 14 "sync/atomic" 15 "testing" 16 17 "github.com/anthropics/anthropic-sdk-go" 18 "github.com/anthropics/anthropic-sdk-go/config" 19 "github.com/anthropics/anthropic-sdk-go/internal/auth" 20 "github.com/anthropics/anthropic-sdk-go/option" 21 ) 22 23 const successResponse = `{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"model":"claude-sonnet-4-20250514","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` 24 25 // unsetEnv removes an env var for the duration of the test. t.Setenv 26 // registers the original value for restore at test end; Unsetenv then 27 // actually removes it for the duration of this test. 28 func unsetEnv(t *testing.T, key string) { 29 t.Helper() 30 t.Setenv(key, "") 31 os.Unsetenv(key) 32 } 33 34 // isolateAuthEnv points ANTHROPIC_CONFIG_DIR at a fresh temp dir and 35 // unsets the federation env vars so a test is not affected by whatever 36 // host profile or env state the developer has configured. 37 func isolateAuthEnv(t *testing.T) { 38 t.Helper() 39 t.Setenv("ANTHROPIC_CONFIG_DIR", t.TempDir()) 40 unsetEnv(t, "ANTHROPIC_PROFILE") 41 unsetEnv(t, "ANTHROPIC_FEDERATION_RULE_ID") 42 unsetEnv(t, "ANTHROPIC_ORGANIZATION_ID") 43 unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN") 44 unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN_FILE") 45 unsetEnv(t, "ANTHROPIC_WORKSPACE_ID") 46 } 47 48 var defaultParams = anthropic.MessageNewParams{ 49 Model: "claude-sonnet-4-20250514", 50 MaxTokens: 10, 51 Messages: []anthropic.MessageParam{ 52 anthropic.NewUserMessage(anthropic.NewTextBlock("hi")), 53 }, 54 } 55 56 func TestIntegration_BearerTokenAndBetaHeader(t *testing.T) { 57 unsetEnv(t, "ANTHROPIC_API_KEY") 58 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 59 isolateAuthEnv(t) 60 61 var receivedAuth string 62 var receivedBeta string 63 64 apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 65 receivedAuth = r.Header.Get("Authorization") 66 receivedBeta = r.Header.Get("anthropic-beta") 67 w.Header().Set("Content-Type", "application/json") 68 w.Write([]byte(successResponse)) 69 })) 70 defer apiServer.Close() 71 72 dir := t.TempDir() 73 credPath := filepath.Join(dir, "credentials.json") 74 os.WriteFile(credPath, []byte(`{"type":"oauth_token","access_token":"my-access-tok"}`), 0600) 75 76 client := anthropic.NewClient( 77 option.WithBaseURL(apiServer.URL), 78 option.WithConfig(&config.Config{ 79 AuthenticationInfo: &config.AuthenticationInfo{ 80 Type: config.AuthenticationTypeUserOAuth, 81 CredentialsPath: credPath, 82 UserOAuth: &config.UserOAuth{}, 83 }, 84 }), 85 ) 86 87 _, err := client.Messages.New(context.Background(), defaultParams) 88 if err != nil { 89 t.Fatal(err) 90 } 91 92 if receivedAuth != "Bearer my-access-tok" { 93 t.Fatalf("got Authorization %q, want %q", receivedAuth, "Bearer my-access-tok") 94 } 95 if !strings.Contains(receivedBeta, "oauth-2025-04-20") { 96 t.Fatalf("anthropic-beta %q does not contain API oauth header", receivedBeta) 97 } 98 if strings.Contains(receivedBeta, "oidc-federation-2026-04-01") { 99 t.Fatalf("anthropic-beta %q should not contain federation header on API requests", receivedBeta) 100 } 101 } 102 103 func TestIntegration_WithFederationTokenProvider(t *testing.T) { 104 var tokenExchangeBeta string 105 var apiBeta string 106 var apiAuth string 107 108 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 109 w.Header().Set("Content-Type", "application/json") 110 if r.URL.Path == "/v1/oauth/token" { 111 tokenExchangeBeta = r.Header.Get("anthropic-beta") 112 var body map[string]any 113 _ = json.NewDecoder(r.Body).Decode(&body) 114 if body["assertion"] != "custom-jwt" { 115 t.Errorf("got assertion %v, want %q", body["assertion"], "custom-jwt") 116 } 117 if body["workspace_id"] != "wrkspc_x" { 118 t.Errorf("got workspace_id %v, want %q", body["workspace_id"], "wrkspc_x") 119 } 120 json.NewEncoder(w).Encode(map[string]any{"access_token": "exchanged-tok"}) 121 return 122 } 123 apiBeta = r.Header.Get("anthropic-beta") 124 apiAuth = r.Header.Get("Authorization") 125 w.Write([]byte(successResponse)) 126 })) 127 defer server.Close() 128 129 unsetEnv(t, "ANTHROPIC_API_KEY") 130 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 131 isolateAuthEnv(t) 132 t.Setenv("ANTHROPIC_BASE_URL", server.URL) 133 134 var providerCalls int 135 client := anthropic.NewClient( 136 option.WithFederationTokenProvider( 137 func(_ context.Context) (string, error) { 138 providerCalls++ 139 return "custom-jwt", nil 140 }, 141 option.FederationOptions{ 142 FederationRuleID: "rule-1", 143 OrganizationID: "org-1", 144 WorkspaceID: "wrkspc_x", 145 }, 146 ), 147 ) 148 149 _, err := client.Messages.New(context.Background(), defaultParams) 150 if err != nil { 151 t.Fatal(err) 152 } 153 if providerCalls != 1 { 154 t.Fatalf("expected provider called once, got %d", providerCalls) 155 } 156 if !strings.Contains(tokenExchangeBeta, "oidc-federation-2026-04-01") { 157 t.Fatalf("token exchange beta %q missing federation header", tokenExchangeBeta) 158 } 159 if apiAuth != "Bearer exchanged-tok" { 160 t.Fatalf("got api auth %q, want %q", apiAuth, "Bearer exchanged-tok") 161 } 162 if !strings.Contains(apiBeta, "oauth-2025-04-20") { 163 t.Fatalf("api beta %q missing API oauth header", apiBeta) 164 } 165 } 166 167 func TestIntegration_TokenCachedAcrossRequests(t *testing.T) { 168 unsetEnv(t, "ANTHROPIC_API_KEY") 169 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 170 isolateAuthEnv(t) 171 172 var tokenCalls atomic.Int32 173 174 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 175 w.Header().Set("Content-Type", "application/json") 176 if r.URL.Path == "/v1/oauth/token" { 177 tokenCalls.Add(1) 178 expiresIn := 3600 179 json.NewEncoder(w).Encode(map[string]any{ 180 "access_token": "cached-tok", 181 "expires_in": expiresIn, 182 }) 183 return 184 } 185 w.Write([]byte(successResponse)) 186 })) 187 defer server.Close() 188 189 dir := t.TempDir() 190 tokenPath := filepath.Join(dir, "token") 191 os.WriteFile(tokenPath, []byte("my-jwt"), 0600) 192 193 client := anthropic.NewClient( 194 option.WithConfig(&config.Config{ 195 BaseURL: server.URL, 196 OrganizationID: "org-1", 197 AuthenticationInfo: &config.AuthenticationInfo{ 198 Type: config.AuthenticationTypeOIDCFederation, 199 CredentialsPath: filepath.Join(dir, "credentials.json"), 200 OIDCFederation: &config.OIDCFederation{ 201 FederationRuleID: "rule-1", 202 IdentityToken: &config.IdentityTokenConfig{ 203 Source: config.IdentityTokenSourceFile, 204 Path: tokenPath, 205 }, 206 }, 207 }, 208 }), 209 ) 210 211 for range 3 { 212 _, err := client.Messages.New(context.Background(), defaultParams) 213 if err != nil { 214 t.Fatal(err) 215 } 216 } 217 218 if n := tokenCalls.Load(); n != 1 { 219 t.Fatalf("expected 1 token exchange call, got %d", n) 220 } 221 } 222 223 // TestIntegration_InMemoryFederationConfigSendsWorkspaceID verifies that an 224 // in-memory federation config passed via option.WithConfig forwards the 225 // top-level workspace_id into the jwt-bearer exchange body — and ONLY there. 226 // The anthropic-workspace-id header must be suppressed on API requests for 227 // federation profiles (the minted token is already workspace-scoped, so the 228 // header would be ignored). This mirrors the file-loaded path in 229 // loadOIDCFederationProfile, which omits WorkspaceID from CredentialsResult. 230 func TestIntegration_InMemoryFederationConfigSendsWorkspaceID(t *testing.T) { 231 unsetEnv(t, "ANTHROPIC_API_KEY") 232 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 233 isolateAuthEnv(t) 234 235 var exchangeBody map[string]any 236 var apiWorkspaceHeader string 237 var apiWorkspaceHeaderPresent bool 238 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 239 w.Header().Set("Content-Type", "application/json") 240 if r.URL.Path == "/v1/oauth/token" { 241 _ = json.NewDecoder(r.Body).Decode(&exchangeBody) 242 json.NewEncoder(w).Encode(map[string]any{"access_token": "exchanged-tok"}) 243 return 244 } 245 apiWorkspaceHeader = r.Header.Get("anthropic-workspace-id") 246 _, apiWorkspaceHeaderPresent = r.Header[http.CanonicalHeaderKey("anthropic-workspace-id")] 247 w.Write([]byte(successResponse)) 248 })) 249 defer server.Close() 250 251 dir := t.TempDir() 252 tokenPath := filepath.Join(dir, "token") 253 os.WriteFile(tokenPath, []byte("my-jwt"), 0600) 254 255 client := anthropic.NewClient( 256 option.WithConfig(&config.Config{ 257 BaseURL: server.URL, 258 OrganizationID: "org-1", 259 WorkspaceID: "wrkspc_x", 260 AuthenticationInfo: &config.AuthenticationInfo{ 261 Type: config.AuthenticationTypeOIDCFederation, 262 CredentialsPath: filepath.Join(dir, "credentials.json"), 263 OIDCFederation: &config.OIDCFederation{ 264 FederationRuleID: "rule-1", 265 IdentityToken: &config.IdentityTokenConfig{ 266 Source: config.IdentityTokenSourceFile, 267 Path: tokenPath, 268 }, 269 }, 270 }, 271 }), 272 ) 273 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 274 t.Fatal(err) 275 } 276 if exchangeBody["workspace_id"] != "wrkspc_x" { 277 t.Fatalf("got exchange-body workspace_id %v, want %q", exchangeBody["workspace_id"], "wrkspc_x") 278 } 279 if apiWorkspaceHeaderPresent { 280 t.Fatalf("anthropic-workspace-id header should be suppressed for in-memory federation config, got %q", apiWorkspaceHeader) 281 } 282 } 283 284 // TestIntegration_InMemoryNonFederationConfigSendsWorkspaceIDHeader is the 285 // regression counterpart to the federation test above: a non-federation 286 // in-memory config (user_oauth) with a top-level workspace_id must still 287 // emit the anthropic-workspace-id header on API requests. The federation 288 // suppression is gated on AuthenticationInfo.Type, not on whether the 289 // config is in-memory. 290 func TestIntegration_InMemoryNonFederationConfigSendsWorkspaceIDHeader(t *testing.T) { 291 unsetEnv(t, "ANTHROPIC_API_KEY") 292 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 293 isolateAuthEnv(t) 294 295 var apiWorkspaceHeader string 296 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 297 apiWorkspaceHeader = r.Header.Get("anthropic-workspace-id") 298 w.Header().Set("Content-Type", "application/json") 299 w.Write([]byte(successResponse)) 300 })) 301 defer server.Close() 302 303 dir := t.TempDir() 304 credPath := filepath.Join(dir, "credentials.json") 305 os.WriteFile(credPath, []byte(`{"type":"oauth_token","access_token":"my-access-tok"}`), 0600) 306 307 client := anthropic.NewClient( 308 option.WithConfig(&config.Config{ 309 BaseURL: server.URL, 310 WorkspaceID: "wrkspc_y", 311 AuthenticationInfo: &config.AuthenticationInfo{ 312 Type: config.AuthenticationTypeUserOAuth, 313 CredentialsPath: credPath, 314 UserOAuth: &config.UserOAuth{}, 315 }, 316 }), 317 ) 318 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 319 t.Fatal(err) 320 } 321 if apiWorkspaceHeader != "wrkspc_y" { 322 t.Fatalf("got anthropic-workspace-id header %q, want %q", apiWorkspaceHeader, "wrkspc_y") 323 } 324 } 325 326 // TestIntegration_EnvWorkspaceIDFillsUserOAuthHeader verifies that 327 // ANTHROPIC_WORKSPACE_ID fills workspace_id uniformly across profile types — 328 // not just federation. This pins the precedence model: explicit config > 329 // env var > profile, regardless of authentication.type. For user_oauth the 330 // filled value surfaces as the anthropic-workspace-id request header 331 // (federation routes it into the exchange body instead). 332 func TestIntegration_EnvWorkspaceIDFillsUserOAuthHeader(t *testing.T) { 333 unsetEnv(t, "ANTHROPIC_API_KEY") 334 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 335 isolateAuthEnv(t) 336 337 var apiWorkspaceHeader string 338 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 339 apiWorkspaceHeader = r.Header.Get("anthropic-workspace-id") 340 w.Header().Set("Content-Type", "application/json") 341 w.Write([]byte(successResponse)) 342 })) 343 defer server.Close() 344 345 dir := t.TempDir() 346 configsDir := filepath.Join(dir, "configs") 347 credsDir := filepath.Join(dir, "credentials") 348 os.MkdirAll(configsDir, 0755) 349 os.MkdirAll(credsDir, 0700) 350 // user_oauth profile with NO workspace_id; ANTHROPIC_WORKSPACE_ID 351 // fills the missing top-level config key. 352 os.WriteFile(filepath.Join(configsDir, "default.json"), []byte(`{ 353 "base_url": "`+server.URL+`", 354 "authentication": {"type": "user_oauth"} 355 }`), 0644) 356 os.WriteFile(filepath.Join(credsDir, "default.json"), 357 []byte(`{"type":"oauth_token","access_token":"tok"}`), 0600) 358 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 359 t.Setenv("ANTHROPIC_WORKSPACE_ID", "wrkspc_env") 360 361 cfg, err := config.LoadConfig() 362 if err != nil { 363 t.Fatal(err) 364 } 365 if cfg.WorkspaceID != "wrkspc_env" { 366 t.Fatalf("got cfg.WorkspaceID %q, want env fill-in", cfg.WorkspaceID) 367 } 368 369 client := anthropic.NewClient(option.WithConfig(cfg)) 370 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 371 t.Fatal(err) 372 } 373 if apiWorkspaceHeader != "wrkspc_env" { 374 t.Fatalf("got anthropic-workspace-id header %q, want %q", apiWorkspaceHeader, "wrkspc_env") 375 } 376 } 377 378 func TestIntegration_PerRequestWorkspaceIDOverridesConfigHeader(t *testing.T) { 379 unsetEnv(t, "ANTHROPIC_API_KEY") 380 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 381 isolateAuthEnv(t) 382 383 var apiWorkspaceHeaders []string 384 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 385 apiWorkspaceHeaders = r.Header.Values("anthropic-workspace-id") 386 w.Header().Set("Content-Type", "application/json") 387 w.Write([]byte(successResponse)) 388 })) 389 defer server.Close() 390 391 dir := t.TempDir() 392 credPath := filepath.Join(dir, "credentials.json") 393 os.WriteFile(credPath, []byte(`{"type":"oauth_token","access_token":"my-access-tok"}`), 0600) 394 395 client := anthropic.NewClient( 396 option.WithConfig(&config.Config{ 397 BaseURL: server.URL, 398 WorkspaceID: "wrkspc_config", 399 AuthenticationInfo: &config.AuthenticationInfo{ 400 Type: config.AuthenticationTypeUserOAuth, 401 CredentialsPath: credPath, 402 UserOAuth: &config.UserOAuth{}, 403 }, 404 }), 405 ) 406 params := defaultParams 407 params.WorkspaceID = anthropic.String("wrkspc_request") 408 if _, err := client.Messages.New(context.Background(), params); err != nil { 409 t.Fatal(err) 410 } 411 if len(apiWorkspaceHeaders) != 1 || apiWorkspaceHeaders[0] != "wrkspc_request" { 412 t.Fatalf("got anthropic-workspace-id header %q, want single %q", apiWorkspaceHeaders, "wrkspc_request") 413 } 414 } 415 416 func TestIntegration_401RetryWithInvalidation(t *testing.T) { 417 unsetEnv(t, "ANTHROPIC_API_KEY") 418 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 419 isolateAuthEnv(t) 420 421 var apiCalls atomic.Int32 422 var tokenCalls atomic.Int32 423 424 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 425 w.Header().Set("Content-Type", "application/json") 426 if r.URL.Path == "/v1/oauth/token" { 427 n := tokenCalls.Add(1) 428 json.NewEncoder(w).Encode(map[string]any{ 429 "access_token": "tok-" + string(rune('0'+n)), 430 }) 431 return 432 } 433 n := apiCalls.Add(1) 434 if n == 1 { 435 w.WriteHeader(http.StatusUnauthorized) 436 w.Write([]byte(`{}`)) 437 return 438 } 439 w.Write([]byte(successResponse)) 440 })) 441 defer server.Close() 442 443 dir := t.TempDir() 444 tokenPath := filepath.Join(dir, "token") 445 os.WriteFile(tokenPath, []byte("my-jwt"), 0600) 446 447 client := anthropic.NewClient( 448 option.WithConfig(&config.Config{ 449 BaseURL: server.URL, 450 OrganizationID: "org-1", 451 AuthenticationInfo: &config.AuthenticationInfo{ 452 Type: config.AuthenticationTypeOIDCFederation, 453 CredentialsPath: filepath.Join(dir, "credentials.json"), 454 OIDCFederation: &config.OIDCFederation{ 455 FederationRuleID: "rule-1", 456 IdentityToken: &config.IdentityTokenConfig{ 457 Source: config.IdentityTokenSourceFile, 458 Path: tokenPath, 459 }, 460 }, 461 }, 462 }), 463 option.WithMaxRetries(0), // disable normal retries to isolate 401 retry 464 ) 465 466 _, err := client.Messages.New(context.Background(), defaultParams) 467 if err != nil { 468 t.Fatal(err) 469 } 470 471 if n := apiCalls.Load(); n != 2 { 472 t.Fatalf("expected 2 API calls (initial + retry), got %d", n) 473 } 474 if n := tokenCalls.Load(); n != 2 { 475 t.Fatalf("expected 2 token calls (initial + after invalidation), got %d", n) 476 } 477 } 478 479 func TestIntegration_ZeroConfigWorkloadIdentity(t *testing.T) { 480 // Single server handles both OAuth token exchange and API requests. 481 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 482 w.Header().Set("Content-Type", "application/json") 483 if r.URL.Path == "/v1/oauth/token" { 484 json.NewEncoder(w).Encode(map[string]any{ 485 "access_token": "zero-config-tok", 486 }) 487 return 488 } 489 authHeader := r.Header.Get("Authorization") 490 if authHeader != "Bearer zero-config-tok" { 491 t.Errorf("got Authorization %q, want %q", authHeader, "Bearer zero-config-tok") 492 } 493 w.Write([]byte(successResponse)) 494 })) 495 defer server.Close() 496 497 unsetEnv(t, "ANTHROPIC_API_KEY") 498 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 499 isolateAuthEnv(t) 500 unsetEnv(t, "ANTHROPIC_CREDENTIALS_FILE") 501 unsetEnv(t, "ANTHROPIC_PROFILE") 502 503 // Point the config dir at an empty temp dir so the default credential 504 // chain falls through the profile step to the env-federation step 505 // instead of finding whatever profile the test host already has. 506 t.Setenv("ANTHROPIC_CONFIG_DIR", t.TempDir()) 507 508 // Set base URL via env so DefaultClientOptions picks it up for both 509 // the API and the OAuth token endpoint. 510 t.Setenv("ANTHROPIC_BASE_URL", server.URL) 511 t.Setenv("ANTHROPIC_IDENTITY_TOKEN", "my-jwt") 512 t.Setenv("ANTHROPIC_FEDERATION_RULE_ID", "rule-1") 513 t.Setenv("ANTHROPIC_ORGANIZATION_ID", "org-1") 514 515 client := anthropic.NewClient() 516 517 _, err := client.Messages.New(context.Background(), defaultParams) 518 if err != nil { 519 t.Fatal(err) 520 } 521 } 522 523 // TestIntegration_ZeroConfigProfile verifies that anthropic.NewClient() with 524 // no options picks up a profile from the config directory, per the spec's 525 // credential precedence chain. 526 func TestIntegration_ZeroConfigProfile(t *testing.T) { 527 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 528 w.Header().Set("Content-Type", "application/json") 529 if got := r.Header.Get("Authorization"); got != "Bearer profile-tok" { 530 t.Errorf("got Authorization %q, want %q", got, "Bearer profile-tok") 531 } 532 w.Write([]byte(successResponse)) 533 })) 534 defer server.Close() 535 536 unsetEnv(t, "ANTHROPIC_API_KEY") 537 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 538 isolateAuthEnv(t) 539 unsetEnv(t, "ANTHROPIC_FEDERATION_RULE_ID") 540 unsetEnv(t, "ANTHROPIC_ORGANIZATION_ID") 541 unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN_FILE") 542 unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN") 543 unsetEnv(t, "ANTHROPIC_PROFILE") 544 545 dir := t.TempDir() 546 os.MkdirAll(filepath.Join(dir, "configs"), 0755) 547 os.MkdirAll(filepath.Join(dir, "credentials"), 0700) 548 os.WriteFile( 549 filepath.Join(dir, "configs", "default.json"), 550 []byte(`{"authentication":{"type":"user_oauth"}}`), 551 0644, 552 ) 553 os.WriteFile( 554 filepath.Join(dir, "credentials", "default.json"), 555 []byte(`{"type":"oauth_token","access_token":"profile-tok"}`), 556 0600, 557 ) 558 559 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 560 t.Setenv("ANTHROPIC_BASE_URL", server.URL) 561 562 client := anthropic.NewClient() 563 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 564 t.Fatalf("zero-config NewClient() should find the default profile: %v", err) 565 } 566 } 567 568 func TestIntegration_WithProfile(t *testing.T) { 569 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 570 w.Header().Set("Content-Type", "application/json") 571 if got := r.Header.Get("Authorization"); got != "Bearer staging-tok" { 572 t.Errorf("got Authorization %q, want %q", got, "Bearer staging-tok") 573 } 574 w.Write([]byte(successResponse)) 575 })) 576 defer server.Close() 577 578 unsetEnv(t, "ANTHROPIC_API_KEY") 579 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 580 isolateAuthEnv(t) 581 582 dir := t.TempDir() 583 os.MkdirAll(filepath.Join(dir, "configs"), 0755) 584 os.MkdirAll(filepath.Join(dir, "credentials"), 0700) 585 os.WriteFile( 586 filepath.Join(dir, "configs", "staging.json"), 587 []byte(`{"authentication":{"type":"user_oauth"}}`), 588 0644, 589 ) 590 os.WriteFile( 591 filepath.Join(dir, "credentials", "staging.json"), 592 []byte(`{"type":"oauth_token","access_token":"staging-tok"}`), 593 0600, 594 ) 595 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 596 597 client := anthropic.NewClient( 598 option.WithoutEnvironmentDefaults(), 599 option.WithBaseURL(server.URL), 600 option.WithProfile("staging"), 601 ) 602 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 603 t.Fatalf("WithProfile should authenticate from the named profile: %v", err) 604 } 605 } 606 607 func TestIntegration_WithProfile_MissingProfileSurfacesError(t *testing.T) { 608 unsetEnv(t, "ANTHROPIC_API_KEY") 609 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 610 isolateAuthEnv(t) 611 612 client := anthropic.NewClient( 613 option.WithoutEnvironmentDefaults(), 614 option.WithBaseURL("http://unused.invalid"), 615 option.WithMaxRetries(0), 616 option.WithProfile("does-not-exist"), 617 ) 618 _, err := client.Messages.New(context.Background(), defaultParams) 619 if err == nil { 620 t.Fatal("expected load error from WithProfile with missing profile") 621 } 622 if !strings.Contains(err.Error(), `WithProfile("does-not-exist")`) { 623 t.Fatalf("error should name the profile and option, got: %v", err) 624 } 625 } 626 627 func TestIntegration_WithProfile_APIKeyPreemptsLoadError(t *testing.T) { 628 unsetEnv(t, "ANTHROPIC_API_KEY") 629 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 630 isolateAuthEnv(t) 631 632 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 633 w.Header().Set("Content-Type", "application/json") 634 if got := r.Header.Get("X-Api-Key"); got != "sk-test-key" { 635 t.Errorf("got X-Api-Key %q, want %q", got, "sk-test-key") 636 } 637 w.Write([]byte(successResponse)) 638 })) 639 defer server.Close() 640 641 client := anthropic.NewClient( 642 option.WithoutEnvironmentDefaults(), 643 option.WithBaseURL(server.URL), 644 option.WithProfile("does-not-exist"), 645 option.WithAPIKey("sk-test-key"), 646 ) 647 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 648 t.Fatalf("WithAPIKey should preempt a broken WithProfile: %v", err) 649 } 650 } 651 652 func TestIntegration_WithProfile_EmptyName(t *testing.T) { 653 isolateAuthEnv(t) 654 client := anthropic.NewClient( 655 option.WithoutEnvironmentDefaults(), 656 option.WithBaseURL("http://unused.invalid"), 657 option.WithMaxRetries(0), 658 option.WithProfile(""), 659 ) 660 _, err := client.Messages.New(context.Background(), defaultParams) 661 if err == nil || !strings.Contains(err.Error(), "profile name is empty") { 662 t.Fatalf("expected empty-name error, got: %v", err) 663 } 664 } 665 666 // TestIntegration_NoCredentialsAggregatedError verifies that when no source 667 // provides credentials, the first API request returns an aggregated error 668 // listing every source the SDK tried and what each failed with. 669 func TestIntegration_NoCredentialsAggregatedError(t *testing.T) { 670 unsetEnv(t, "ANTHROPIC_API_KEY") 671 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 672 isolateAuthEnv(t) 673 unsetEnv(t, "ANTHROPIC_FEDERATION_RULE_ID") 674 unsetEnv(t, "ANTHROPIC_ORGANIZATION_ID") 675 unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN_FILE") 676 unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN") 677 unsetEnv(t, "ANTHROPIC_PROFILE") 678 679 dir := t.TempDir() // empty config dir - no profile file 680 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 681 t.Setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:1") // never reached 682 683 client := anthropic.NewClient() 684 _, err := client.Messages.New(context.Background(), defaultParams) 685 if err == nil { 686 t.Fatal("expected error when no credentials source is configured") 687 } 688 msg := err.Error() 689 if !strings.Contains(msg, "no Anthropic credentials") { 690 t.Errorf("expected aggregated error message, got: %v", err) 691 } 692 if !strings.Contains(msg, "ANTHROPIC_API_KEY") { 693 t.Errorf("expected source list to mention ANTHROPIC_API_KEY, got: %v", err) 694 } 695 if !strings.Contains(msg, "profile") { 696 t.Errorf("expected source list to mention profile, got: %v", err) 697 } 698 if !strings.Contains(msg, "federation") { 699 t.Errorf("expected source list to mention federation, got: %v", err) 700 } 701 if !strings.Contains(msg, "ant auth login") { 702 t.Errorf("expected remediation hint in message, got: %v", err) 703 } 704 } 705 706 // TestIntegration_NoCredentialsPartialFederation verifies that when the user 707 // set some-but-not-all federation env vars, the aggregated error tells them 708 // which specific vars are missing instead of silently falling through to the 709 // "nothing is set" case. 710 func TestIntegration_NoCredentialsPartialFederation(t *testing.T) { 711 unsetEnv(t, "ANTHROPIC_API_KEY") 712 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 713 isolateAuthEnv(t) 714 unsetEnv(t, "ANTHROPIC_ORGANIZATION_ID") 715 unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN_FILE") 716 unsetEnv(t, "ANTHROPIC_IDENTITY_TOKEN") 717 unsetEnv(t, "ANTHROPIC_PROFILE") 718 719 dir := t.TempDir() 720 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 721 t.Setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:1") 722 t.Setenv("ANTHROPIC_FEDERATION_RULE_ID", "rule-1") // only one of three set 723 724 client := anthropic.NewClient() 725 _, err := client.Messages.New(context.Background(), defaultParams) 726 if err == nil { 727 t.Fatal("expected error when federation env vars are only partially set") 728 } 729 msg := err.Error() 730 if !strings.Contains(msg, "partial configuration") { 731 t.Errorf("expected aggregated error to mark the env federation source as partial, got: %v", err) 732 } 733 if !strings.Contains(msg, "missing: ANTHROPIC_ORGANIZATION_ID") { 734 t.Errorf("expected partial-config detail to name missing ANTHROPIC_ORGANIZATION_ID first, got: %v", err) 735 } 736 } 737 738 // TestIntegration_AggregatedErrorNotShownWhenKeySet verifies the aggregated 739 // error does NOT fire when a credential source actually works. 740 func TestIntegration_AggregatedErrorNotShownWhenKeySet(t *testing.T) { 741 var sawHeader string 742 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 743 sawHeader = r.Header.Get("X-Api-Key") 744 w.Header().Set("Content-Type", "application/json") 745 w.Write([]byte(successResponse)) 746 })) 747 defer server.Close() 748 749 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 750 isolateAuthEnv(t) 751 unsetEnv(t, "ANTHROPIC_PROFILE") 752 t.Setenv("ANTHROPIC_API_KEY", "sk-test-key") 753 t.Setenv("ANTHROPIC_CONFIG_DIR", t.TempDir()) 754 t.Setenv("ANTHROPIC_BASE_URL", server.URL) 755 756 client := anthropic.NewClient() 757 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 758 t.Fatalf("expected success when API key is set: %v", err) 759 } 760 if sawHeader != "sk-test-key" { 761 t.Fatalf("API key not forwarded: %q", sawHeader) 762 } 763 } 764 765 func TestIntegration_APIKeyPrecedencePreserved(t *testing.T) { 766 unsetEnv(t, "ANTHROPIC_API_KEY") 767 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 768 isolateAuthEnv(t) 769 770 var receivedHeaders http.Header 771 772 apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 773 receivedHeaders = r.Header 774 w.Header().Set("Content-Type", "application/json") 775 w.Write([]byte(successResponse)) 776 })) 777 defer apiServer.Close() 778 779 client := anthropic.NewClient( 780 option.WithBaseURL(apiServer.URL), 781 option.WithAPIKey("sk-test-key"), 782 ) 783 784 _, err := client.Messages.New(context.Background(), defaultParams) 785 if err != nil { 786 t.Fatal(err) 787 } 788 789 if receivedHeaders.Get("Authorization") != "" { 790 t.Fatal("expected no Authorization header when using API key") 791 } 792 if receivedHeaders.Get("X-Api-Key") != "sk-test-key" { 793 t.Fatalf("got X-Api-Key %q, want %q", receivedHeaders.Get("X-Api-Key"), "sk-test-key") 794 } 795 } 796 797 // TestIntegration_APIKeyWithProfileLogsWarning verifies that when a static 798 // ANTHROPIC_API_KEY is passed alongside a profile config, the API key still 799 // wins (per the spec's credential precedence) but the user gets a one-shot 800 // warning so they can figure out why their profile's credentials are being 801 // ignored. 802 func TestIntegration_APIKeyWithProfileLogsWarning(t *testing.T) { 803 unsetEnv(t, "ANTHROPIC_API_KEY") 804 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 805 isolateAuthEnv(t) 806 807 var receivedHeaders http.Header 808 apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 809 receivedHeaders = r.Header 810 w.Header().Set("Content-Type", "application/json") 811 w.Write([]byte(successResponse)) 812 })) 813 defer apiServer.Close() 814 815 dir := t.TempDir() 816 credPath := filepath.Join(dir, "credentials.json") 817 os.WriteFile(credPath, []byte(`{"type":"oauth_token","access_token":"profile-tok"}`), 0600) 818 819 var logMu sync.Mutex 820 var logBuf bytes.Buffer 821 log.SetOutput(writerFunc(func(p []byte) (int, error) { 822 logMu.Lock() 823 defer logMu.Unlock() 824 return logBuf.Write(p) 825 })) 826 t.Cleanup(func() { log.SetOutput(os.Stderr) }) 827 readLog := func() string { 828 logMu.Lock() 829 defer logMu.Unlock() 830 return logBuf.String() 831 } 832 833 client := anthropic.NewClient( 834 option.WithBaseURL(apiServer.URL), 835 option.WithAPIKey("sk-test-key"), 836 option.WithConfig(&config.Config{ 837 AuthenticationInfo: &config.AuthenticationInfo{ 838 Type: config.AuthenticationTypeUserOAuth, 839 CredentialsPath: credPath, 840 UserOAuth: &config.UserOAuth{}, 841 }, 842 }), 843 ) 844 845 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 846 t.Fatal(err) 847 } 848 849 if receivedHeaders.Get("X-Api-Key") != "sk-test-key" { 850 t.Fatalf("got X-Api-Key %q, want %q", receivedHeaders.Get("X-Api-Key"), "sk-test-key") 851 } 852 if receivedHeaders.Get("Authorization") == "Bearer profile-tok" { 853 t.Fatal("profile credential should not have won over static API key") 854 } 855 856 out := readLog() 857 if !strings.Contains(out, "ANTHROPIC_API_KEY") { 858 t.Fatalf("expected warning mentioning ANTHROPIC_API_KEY, got: %q", out) 859 } 860 if !strings.Contains(out, "profile") && !strings.Contains(out, "config") { 861 t.Fatalf("expected warning to mention the profile/config being shadowed, got: %q", out) 862 } 863 } 864 865 // TestIntegration_WithConfigQuiet_SuppressesShadowWarning verifies that 866 // option.WithConfigQuiet behaves identically to WithConfig for credential 867 // precedence (API key still wins) but does NOT emit the config-shadowed 868 // warning, so callers with their own diagnostic don't double-warn. 869 func TestIntegration_WithConfigQuiet_SuppressesShadowWarning(t *testing.T) { 870 unsetEnv(t, "ANTHROPIC_API_KEY") 871 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 872 isolateAuthEnv(t) 873 874 var receivedHeaders http.Header 875 apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 876 receivedHeaders = r.Header 877 w.Header().Set("Content-Type", "application/json") 878 w.Write([]byte(successResponse)) 879 })) 880 defer apiServer.Close() 881 882 dir := t.TempDir() 883 credPath := filepath.Join(dir, "credentials.json") 884 os.WriteFile(credPath, []byte(`{"type":"oauth_token","access_token":"profile-tok"}`), 0600) 885 886 var logMu sync.Mutex 887 var logBuf bytes.Buffer 888 log.SetOutput(writerFunc(func(p []byte) (int, error) { 889 logMu.Lock() 890 defer logMu.Unlock() 891 return logBuf.Write(p) 892 })) 893 t.Cleanup(func() { log.SetOutput(os.Stderr) }) 894 895 client := anthropic.NewClient( 896 option.WithBaseURL(apiServer.URL), 897 option.WithAPIKey("sk-test-key"), 898 option.WithConfigQuiet(&config.Config{ 899 AuthenticationInfo: &config.AuthenticationInfo{ 900 Type: config.AuthenticationTypeUserOAuth, 901 CredentialsPath: credPath, 902 UserOAuth: &config.UserOAuth{}, 903 }, 904 }), 905 ) 906 907 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 908 t.Fatal(err) 909 } 910 911 if receivedHeaders.Get("X-Api-Key") != "sk-test-key" { 912 t.Fatalf("got X-Api-Key %q, want %q (API key precedence must be unchanged)", 913 receivedHeaders.Get("X-Api-Key"), "sk-test-key") 914 } 915 916 logMu.Lock() 917 out := logBuf.String() 918 logMu.Unlock() 919 if out != "" { 920 t.Fatalf("expected no log output from WithConfigQuiet, got: %q", out) 921 } 922 } 923 924 // TestIntegration_WithoutEnvironmentDefaults_NoAutoloadShadowWarning proves 925 // that when the environment would otherwise autoload a profile (via 926 // ANTHROPIC_PROFILE), passing option.WithoutEnvironmentDefaults() causes 927 // NewClient to skip DefaultClientOptions entirely so no autoloaded 928 // WithConfig is added — and therefore an explicit WithAPIKey produces zero 929 // shadow warnings. This closes the gap WithConfigQuiet alone leaves: it 930 // quiets the caller's own WithConfig but not the SDK's autoloaded one. 931 func TestIntegration_WithoutEnvironmentDefaults_NoAutoloadShadowWarning(t *testing.T) { 932 unsetEnv(t, "ANTHROPIC_API_KEY") 933 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 934 isolateAuthEnv(t) 935 auth.ResetWarnOnceForTest() 936 937 // Stage a profile under ANTHROPIC_CONFIG_DIR and point ANTHROPIC_PROFILE 938 // at it so DefaultClientOptions, if invoked, would inject a WithConfig. 939 cfgDir := os.Getenv("ANTHROPIC_CONFIG_DIR") 940 credPath := config.ProfileCredentialsPath(cfgDir, "work") 941 if err := config.WriteCredentials(credPath, config.Credentials{AccessToken: "profile-tok"}); err != nil { 942 t.Fatal(err) 943 } 944 if err := config.SaveProfile(cfgDir, "work", &config.Config{ 945 AuthenticationInfo: &config.AuthenticationInfo{ 946 Type: config.AuthenticationTypeUserOAuth, 947 CredentialsPath: credPath, 948 UserOAuth: &config.UserOAuth{}, 949 }, 950 }); err != nil { 951 t.Fatal(err) 952 } 953 t.Setenv("ANTHROPIC_PROFILE", "work") 954 955 var receivedHeaders http.Header 956 apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 957 receivedHeaders = r.Header 958 w.Header().Set("Content-Type", "application/json") 959 w.Write([]byte(successResponse)) 960 })) 961 defer apiServer.Close() 962 963 var logMu sync.Mutex 964 var logBuf bytes.Buffer 965 log.SetOutput(writerFunc(func(p []byte) (int, error) { 966 logMu.Lock() 967 defer logMu.Unlock() 968 return logBuf.Write(p) 969 })) 970 t.Cleanup(func() { log.SetOutput(os.Stderr) }) 971 972 client := anthropic.NewClient( 973 option.WithoutEnvironmentDefaults(), 974 option.WithBaseURL(apiServer.URL), 975 option.WithAPIKey("sk-test-key"), 976 ) 977 978 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 979 t.Fatal(err) 980 } 981 982 if receivedHeaders.Get("X-Api-Key") != "sk-test-key" { 983 t.Fatalf("got X-Api-Key %q, want sk-test-key", receivedHeaders.Get("X-Api-Key")) 984 } 985 if receivedHeaders.Get("Authorization") != "" { 986 t.Fatalf("autoloaded profile credential leaked: Authorization=%q", receivedHeaders.Get("Authorization")) 987 } 988 989 logMu.Lock() 990 out := logBuf.String() 991 logMu.Unlock() 992 if out != "" { 993 t.Fatalf("expected zero log output with WithoutEnvironmentDefaults, got: %q", out) 994 } 995 } 996 997 type writerFunc func([]byte) (int, error) 998 999 func (f writerFunc) Write(p []byte) (int, error) { return f(p) } 1000 1001 // TestIntegration_SharedFederationOptionPerClientTransport verifies that a 1002 // single WithFederationTokenProvider option value shared across two clients 1003 // with different HTTP transports does not cause the second client's token 1004 // exchange to run on the first client's transport. 1005 func TestIntegration_SharedFederationOptionPerClientTransport(t *testing.T) { 1006 unsetEnv(t, "ANTHROPIC_API_KEY") 1007 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1008 isolateAuthEnv(t) 1009 1010 var serverA, serverB *httptest.Server 1011 serverA = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1012 w.Header().Set("Content-Type", "application/json") 1013 if r.URL.Path == "/v1/oauth/token" { 1014 json.NewEncoder(w).Encode(map[string]any{"access_token": "tok-A"}) 1015 return 1016 } 1017 if got := r.Header.Get("Authorization"); got != "Bearer tok-A" { 1018 t.Errorf("serverA got Authorization %q, want %q", got, "Bearer tok-A") 1019 } 1020 w.Write([]byte(successResponse)) 1021 })) 1022 defer serverA.Close() 1023 serverB = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1024 w.Header().Set("Content-Type", "application/json") 1025 if r.URL.Path == "/v1/oauth/token" { 1026 json.NewEncoder(w).Encode(map[string]any{"access_token": "tok-B"}) 1027 return 1028 } 1029 if got := r.Header.Get("Authorization"); got != "Bearer tok-B" { 1030 t.Errorf("serverB got Authorization %q, want %q", got, "Bearer tok-B") 1031 } 1032 w.Write([]byte(successResponse)) 1033 })) 1034 defer serverB.Close() 1035 1036 // Count which underlying transport each client actually uses for its 1037 // token-exchange traffic. If the fix is wrong, client B's token exchange 1038 // runs on client A's transport and the counter for A sees both. 1039 var aTransportCalls, bTransportCalls atomic.Int32 1040 transportA := &countingTransport{n: &aTransportCalls, next: http.DefaultTransport} 1041 transportB := &countingTransport{n: &bTransportCalls, next: http.DefaultTransport} 1042 1043 // Shared option value, deliberately reused across both clients. 1044 sharedAuth := option.WithFederationTokenProvider( 1045 func(_ context.Context) (string, error) { return "jwt", nil }, 1046 option.FederationOptions{FederationRuleID: "rule-1", OrganizationID: "org-1"}, 1047 ) 1048 1049 clientA := anthropic.NewClient( 1050 option.WithBaseURL(serverA.URL), 1051 option.WithHTTPClient(&http.Client{Transport: transportA}), 1052 sharedAuth, 1053 ) 1054 clientB := anthropic.NewClient( 1055 option.WithBaseURL(serverB.URL), 1056 option.WithHTTPClient(&http.Client{Transport: transportB}), 1057 sharedAuth, 1058 ) 1059 1060 if _, err := clientA.Messages.New(context.Background(), defaultParams); err != nil { 1061 t.Fatal(err) 1062 } 1063 if _, err := clientB.Messages.New(context.Background(), defaultParams); err != nil { 1064 t.Fatal(err) 1065 } 1066 1067 // Each client must have driven its own transport for both the token 1068 // exchange and the API request (2 calls each). 1069 if n := aTransportCalls.Load(); n != 2 { 1070 t.Fatalf("expected 2 calls on transport A, got %d", n) 1071 } 1072 if n := bTransportCalls.Load(); n != 2 { 1073 t.Fatalf("expected 2 calls on transport B, got %d", n) 1074 } 1075 } 1076 1077 type countingTransport struct { 1078 n *atomic.Int32 1079 next http.RoundTripper 1080 } 1081 1082 func (t *countingTransport) RoundTrip(req *http.Request) (*http.Response, error) { 1083 t.n.Add(1) 1084 return t.next.RoundTrip(req) 1085 } 1086 1087 // TestIntegration_WithConfigBaseURLOrderIndependent asserts that an explicit 1088 // WithBaseURL wins over config.BaseURL regardless of the order in which 1089 // WithBaseURL and WithConfig are passed. 1090 func TestIntegration_WithConfigBaseURLOrderIndependent(t *testing.T) { 1091 unsetEnv(t, "ANTHROPIC_API_KEY") 1092 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1093 isolateAuthEnv(t) 1094 1095 var explicitHits atomic.Int32 1096 explicit := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1097 explicitHits.Add(1) 1098 w.Header().Set("Content-Type", "application/json") 1099 w.Write([]byte(successResponse)) 1100 })) 1101 defer explicit.Close() 1102 1103 // config.BaseURL points at an address that would fail if actually used. 1104 cfg := &config.Config{ 1105 BaseURL: "http://127.0.0.1:1", 1106 AuthenticationInfo: &config.AuthenticationInfo{ 1107 Type: config.AuthenticationTypeUserOAuth, 1108 CredentialsPath: writeStaticCred(t), 1109 UserOAuth: &config.UserOAuth{}, 1110 }, 1111 } 1112 1113 for _, name := range []string{"base_first", "config_first"} { 1114 t.Run(name, func(t *testing.T) { 1115 explicitHits.Store(0) 1116 var client anthropic.Client 1117 if name == "base_first" { 1118 client = anthropic.NewClient( 1119 option.WithBaseURL(explicit.URL), 1120 option.WithConfig(cfg), 1121 ) 1122 } else { 1123 client = anthropic.NewClient( 1124 option.WithConfig(cfg), 1125 option.WithBaseURL(explicit.URL), 1126 ) 1127 } 1128 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 1129 t.Fatal(err) 1130 } 1131 if explicitHits.Load() == 0 { 1132 t.Fatal("explicit base URL did not receive the request") 1133 } 1134 }) 1135 } 1136 } 1137 1138 // TestIntegration_WithConfigMiddlewareCountStable makes several requests with 1139 // the same WithConfig option value and checks that each request sees exactly 1140 // one auth middleware invocation per request (not N on the Nth request). 1141 func TestIntegration_WithConfigMiddlewareCountStable(t *testing.T) { 1142 unsetEnv(t, "ANTHROPIC_API_KEY") 1143 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1144 isolateAuthEnv(t) 1145 1146 var apiCalls atomic.Int32 1147 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1148 apiCalls.Add(1) 1149 w.Header().Set("Content-Type", "application/json") 1150 w.Write([]byte(successResponse)) 1151 })) 1152 defer server.Close() 1153 1154 client := anthropic.NewClient( 1155 option.WithBaseURL(server.URL), 1156 option.WithConfig(&config.Config{ 1157 AuthenticationInfo: &config.AuthenticationInfo{ 1158 Type: config.AuthenticationTypeUserOAuth, 1159 CredentialsPath: writeStaticCred(t), 1160 UserOAuth: &config.UserOAuth{}, 1161 }, 1162 }), 1163 ) 1164 1165 for i := 0; i < 5; i++ { 1166 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 1167 t.Fatalf("request %d: %v", i, err) 1168 } 1169 } 1170 if n := apiCalls.Load(); n != 5 { 1171 t.Fatalf("expected 5 API hits, got %d", n) 1172 } 1173 } 1174 1175 func writeStaticCred(t *testing.T) string { 1176 t.Helper() 1177 dir := t.TempDir() 1178 credPath := filepath.Join(dir, "creds.json") 1179 os.WriteFile(credPath, []byte(`{"type":"oauth_token","access_token":"tok"}`), 0600) 1180 return credPath 1181 } 1182 1183 // TestDefaultClient_ExplicitProfileBeatsEnvFederation verifies the spec rule 1184 // that ANTHROPIC_PROFILE takes precedence over the env-var federation path: 1185 // when both are set, the profile's federation_rule_id wins on the wire. 1186 func TestDefaultClient_ExplicitProfileBeatsEnvFederation(t *testing.T) { 1187 var sawBody map[string]any 1188 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1189 w.Header().Set("Content-Type", "application/json") 1190 if r.URL.Path == "/v1/oauth/token" { 1191 _ = json.NewDecoder(r.Body).Decode(&sawBody) 1192 json.NewEncoder(w).Encode(map[string]any{"access_token": "tok"}) 1193 return 1194 } 1195 w.Write([]byte(successResponse)) 1196 })) 1197 defer server.Close() 1198 1199 unsetEnv(t, "ANTHROPIC_API_KEY") 1200 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1201 isolateAuthEnv(t) 1202 1203 dir := t.TempDir() 1204 tokenPath := filepath.Join(dir, "token") 1205 os.WriteFile(tokenPath, []byte("profile-jwt"), 0600) 1206 os.MkdirAll(filepath.Join(dir, "configs"), 0755) 1207 os.WriteFile(filepath.Join(dir, "configs", "dev.json"), []byte(`{ 1208 "authentication": { 1209 "type": "oidc_federation", 1210 "federation_rule_id": "fdrl_from_profile", 1211 "identity_token": {"source": "file", "path": "`+tokenPath+`"} 1212 }, 1213 "organization_id": "org_from_profile" 1214 }`), 0644) 1215 1216 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 1217 t.Setenv("ANTHROPIC_PROFILE", "dev") 1218 t.Setenv("ANTHROPIC_BASE_URL", server.URL) 1219 t.Setenv("ANTHROPIC_FEDERATION_RULE_ID", "fdrl_from_env") 1220 t.Setenv("ANTHROPIC_ORGANIZATION_ID", "org_from_env") 1221 t.Setenv("ANTHROPIC_IDENTITY_TOKEN", "env-jwt") 1222 1223 client := anthropic.NewClient() 1224 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 1225 t.Fatal(err) 1226 } 1227 if got := sawBody["federation_rule_id"]; got != "fdrl_from_profile" { 1228 t.Errorf("exchange federation_rule_id: got %v, want fdrl_from_profile (profile should beat env)", got) 1229 } 1230 if got := sawBody["assertion"]; got != "profile-jwt" { 1231 t.Errorf("exchange assertion: got %v, want profile-jwt (profile's identity_token should win)", got) 1232 } 1233 } 1234 1235 // TestDefaultClient_EnvFederationBeatsFallbackProfile verifies the spec rule 1236 // that a fallback profile (active_config / "default" — no explicit 1237 // ANTHROPIC_PROFILE) loses to the direct env-var federation path. A leftover 1238 // default.json on a WIF-configured machine must not silently replace WIF. 1239 func TestDefaultClient_EnvFederationBeatsFallbackProfile(t *testing.T) { 1240 var sawBody map[string]any 1241 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1242 w.Header().Set("Content-Type", "application/json") 1243 if r.URL.Path == "/v1/oauth/token" { 1244 _ = json.NewDecoder(r.Body).Decode(&sawBody) 1245 json.NewEncoder(w).Encode(map[string]any{"access_token": "tok"}) 1246 return 1247 } 1248 w.Write([]byte(successResponse)) 1249 })) 1250 defer server.Close() 1251 1252 unsetEnv(t, "ANTHROPIC_API_KEY") 1253 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1254 isolateAuthEnv(t) 1255 unsetEnv(t, "ANTHROPIC_PROFILE") 1256 1257 dir := t.TempDir() 1258 os.MkdirAll(filepath.Join(dir, "configs"), 0755) 1259 os.WriteFile(filepath.Join(dir, "configs", "default.json"), []byte(`{ 1260 "authentication": { 1261 "type": "oidc_federation", 1262 "federation_rule_id": "fdrl_from_profile", 1263 "identity_token": {"source": "file", "path": "/tmp/unused"} 1264 }, 1265 "organization_id": "org_from_profile" 1266 }`), 0644) 1267 1268 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 1269 t.Setenv("ANTHROPIC_BASE_URL", server.URL) 1270 t.Setenv("ANTHROPIC_FEDERATION_RULE_ID", "fdrl_from_env") 1271 t.Setenv("ANTHROPIC_ORGANIZATION_ID", "org_from_env") 1272 t.Setenv("ANTHROPIC_IDENTITY_TOKEN", "env-jwt") 1273 1274 client := anthropic.NewClient() 1275 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 1276 t.Fatal(err) 1277 } 1278 if got := sawBody["federation_rule_id"]; got != "fdrl_from_env" { 1279 t.Errorf("exchange federation_rule_id: got %v, want fdrl_from_env (env should beat fallback profile)", got) 1280 } 1281 if got := sawBody["assertion"]; got != "env-jwt" { 1282 t.Errorf("exchange assertion: got %v, want env-jwt", got) 1283 } 1284 } 1285 1286 // TestDefaultClient_ExplicitProfileMissingFails verifies that when 1287 // ANTHROPIC_PROFILE names a profile that does not exist, the SDK surfaces 1288 // the error instead of silently falling through to env federation or the 1289 // "no credentials" aggregate. The user explicitly asked for a profile. 1290 func TestDefaultClient_ExplicitProfileMissingFails(t *testing.T) { 1291 unsetEnv(t, "ANTHROPIC_API_KEY") 1292 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1293 isolateAuthEnv(t) 1294 1295 dir := t.TempDir() 1296 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 1297 t.Setenv("ANTHROPIC_PROFILE", "nonexistent") 1298 t.Setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:1") 1299 1300 client := anthropic.NewClient() 1301 _, err := client.Messages.New(context.Background(), defaultParams) 1302 if err == nil { 1303 t.Fatal("expected an error when ANTHROPIC_PROFILE names a missing profile") 1304 } 1305 msg := err.Error() 1306 if !strings.Contains(msg, "nonexistent") { 1307 t.Errorf("expected error to name the missing profile, got: %v", err) 1308 } 1309 } 1310 1311 // TestDefaultClient_ExplicitAPIKeyOverridesBrokenProfile verifies that a 1312 // caller-supplied option.WithAPIKey preempts the broken-profile error 1313 // from explicitProfileErrorOption. The user is asking for an explicit 1314 // credential override; the shell's ANTHROPIC_PROFILE should not strand them. 1315 func TestDefaultClient_ExplicitAPIKeyOverridesBrokenProfile(t *testing.T) { 1316 unsetEnv(t, "ANTHROPIC_API_KEY") 1317 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1318 isolateAuthEnv(t) 1319 1320 var receivedHeaders http.Header 1321 apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1322 receivedHeaders = r.Header 1323 w.Header().Set("Content-Type", "application/json") 1324 w.Write([]byte(successResponse)) 1325 })) 1326 defer apiServer.Close() 1327 1328 dir := t.TempDir() 1329 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 1330 t.Setenv("ANTHROPIC_PROFILE", "nonexistent") 1331 1332 client := anthropic.NewClient( 1333 option.WithBaseURL(apiServer.URL), 1334 option.WithAPIKey("sk-test-key"), 1335 ) 1336 _, err := client.Messages.New(context.Background(), defaultParams) 1337 if err != nil { 1338 t.Fatalf("expected explicit API key to override broken profile, got: %v", err) 1339 } 1340 if got := receivedHeaders.Get("X-Api-Key"); got != "sk-test-key" { 1341 t.Errorf("X-Api-Key: got %q, want %q", got, "sk-test-key") 1342 } 1343 } 1344 1345 // TestDefaultClient_ExplicitAuthTokenOverridesBrokenProfile is the 1346 // companion for option.WithAuthToken. 1347 func TestDefaultClient_ExplicitAuthTokenOverridesBrokenProfile(t *testing.T) { 1348 unsetEnv(t, "ANTHROPIC_API_KEY") 1349 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1350 isolateAuthEnv(t) 1351 1352 var receivedHeaders http.Header 1353 apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1354 receivedHeaders = r.Header 1355 w.Header().Set("Content-Type", "application/json") 1356 w.Write([]byte(successResponse)) 1357 })) 1358 defer apiServer.Close() 1359 1360 dir := t.TempDir() 1361 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 1362 t.Setenv("ANTHROPIC_PROFILE", "nonexistent") 1363 1364 client := anthropic.NewClient( 1365 option.WithBaseURL(apiServer.URL), 1366 option.WithAuthToken("sk-test-token"), 1367 ) 1368 _, err := client.Messages.New(context.Background(), defaultParams) 1369 if err != nil { 1370 t.Fatalf("expected explicit auth token to override broken profile, got: %v", err) 1371 } 1372 if got := receivedHeaders.Get("Authorization"); got != "Bearer sk-test-token" { 1373 t.Errorf("Authorization: got %q, want %q", got, "Bearer sk-test-token") 1374 } 1375 } 1376 1377 // TestIntegration_APIKeyShadowWarning_InvertedOrder locks in that the 1378 // shadow warning fires regardless of the order the caller passes 1379 // option.WithConfig and option.WithAPIKey. The earlier implementation 1380 // read r.APIKey at WithConfig's Apply time, which missed the case where 1381 // WithAPIKey was passed AFTER WithConfig in the arg list. 1382 func TestIntegration_APIKeyShadowWarning_InvertedOrder(t *testing.T) { 1383 auth.ResetWarnOnceForTest() 1384 unsetEnv(t, "ANTHROPIC_API_KEY") 1385 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1386 isolateAuthEnv(t) 1387 1388 var receivedHeaders http.Header 1389 apiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1390 receivedHeaders = r.Header 1391 w.Header().Set("Content-Type", "application/json") 1392 w.Write([]byte(successResponse)) 1393 })) 1394 defer apiServer.Close() 1395 1396 dir := t.TempDir() 1397 credPath := filepath.Join(dir, "credentials.json") 1398 os.WriteFile(credPath, []byte(`{"type":"oauth_token","access_token":"profile-tok"}`), 0600) 1399 1400 var logMu sync.Mutex 1401 var logBuf bytes.Buffer 1402 log.SetOutput(writerFunc(func(p []byte) (int, error) { 1403 logMu.Lock() 1404 defer logMu.Unlock() 1405 return logBuf.Write(p) 1406 })) 1407 t.Cleanup(func() { log.SetOutput(os.Stderr) }) 1408 readLog := func() string { 1409 logMu.Lock() 1410 defer logMu.Unlock() 1411 return logBuf.String() 1412 } 1413 1414 // Note: WithConfig is passed BEFORE WithAPIKey. The warning must 1415 // still fire because the final request-time state has both. 1416 client := anthropic.NewClient( 1417 option.WithBaseURL(apiServer.URL), 1418 option.WithConfig(&config.Config{ 1419 AuthenticationInfo: &config.AuthenticationInfo{ 1420 Type: config.AuthenticationTypeUserOAuth, 1421 CredentialsPath: credPath, 1422 UserOAuth: &config.UserOAuth{}, 1423 }, 1424 }), 1425 option.WithAPIKey("sk-test-key"), 1426 ) 1427 1428 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 1429 t.Fatal(err) 1430 } 1431 1432 if receivedHeaders.Get("X-Api-Key") != "sk-test-key" { 1433 t.Fatalf("got X-Api-Key %q, want sk-test-key (API key should still win)", receivedHeaders.Get("X-Api-Key")) 1434 } 1435 1436 out := readLog() 1437 if !strings.Contains(out, "ANTHROPIC_API_KEY") { 1438 t.Fatalf("expected warning mentioning ANTHROPIC_API_KEY regardless of option order, got: %q", out) 1439 } 1440 if !strings.Contains(out, "profile") && !strings.Contains(out, "config") { 1441 t.Fatalf("expected warning to mention profile/config being shadowed, got: %q", out) 1442 } 1443 } 1444 1445 // brokenUserOAuthConfig returns a user_oauth Config whose CredentialsPath 1446 // points at a missing file under t.TempDir, so ResolveCredentials errors. 1447 // BaseURL is set so callers can assert it applies independently. 1448 func brokenUserOAuthConfig(t *testing.T, baseURL string) *config.Config { 1449 t.Helper() 1450 return &config.Config{ 1451 BaseURL: baseURL, 1452 AuthenticationInfo: &config.AuthenticationInfo{ 1453 Type: config.AuthenticationTypeUserOAuth, 1454 CredentialsPath: filepath.Join(t.TempDir(), "missing.json"), 1455 UserOAuth: &config.UserOAuth{ClientID: "x"}, 1456 }, 1457 } 1458 } 1459 1460 // TestWithConfig_ExplicitAPIKeyOverridesBrokenProfile verifies that a 1461 // WithAPIKey passed alongside a WithConfig whose credentials cannot be 1462 // resolved still authenticates the request — i.e. the resolution error is 1463 // deferred to a request-time middleware with tier-1 escape hatches, not 1464 // returned at option-Apply time where it would short-circuit later options. 1465 func TestWithConfig_ExplicitAPIKeyOverridesBrokenProfile(t *testing.T) { 1466 auth.ResetWarnOnceForTest() 1467 unsetEnv(t, "ANTHROPIC_API_KEY") 1468 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1469 isolateAuthEnv(t) 1470 1471 var receivedHeaders http.Header 1472 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1473 receivedHeaders = r.Header 1474 w.Header().Set("Content-Type", "application/json") 1475 w.Write([]byte(successResponse)) 1476 })) 1477 defer server.Close() 1478 1479 client := anthropic.NewClient( 1480 option.WithConfig(brokenUserOAuthConfig(t, server.URL)), 1481 option.WithAPIKey("sk-x"), 1482 ) 1483 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 1484 t.Fatalf("expected request to succeed via WithAPIKey despite broken profile, got: %v", err) 1485 } 1486 if got := receivedHeaders.Get("X-Api-Key"); got != "sk-x" { 1487 t.Fatalf("got X-Api-Key %q, want sk-x", got) 1488 } 1489 if receivedHeaders.Get("Authorization") != "" { 1490 t.Fatalf("Authorization should not be set when X-Api-Key is, got %q", receivedHeaders.Get("Authorization")) 1491 } 1492 } 1493 1494 // TestWithConfig_ExplicitAuthTokenOverridesBrokenProfile is the WithAuthToken 1495 // variant of the above. 1496 func TestWithConfig_ExplicitAuthTokenOverridesBrokenProfile(t *testing.T) { 1497 auth.ResetWarnOnceForTest() 1498 unsetEnv(t, "ANTHROPIC_API_KEY") 1499 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1500 isolateAuthEnv(t) 1501 1502 var receivedHeaders http.Header 1503 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1504 receivedHeaders = r.Header 1505 w.Header().Set("Content-Type", "application/json") 1506 w.Write([]byte(successResponse)) 1507 })) 1508 defer server.Close() 1509 1510 client := anthropic.NewClient( 1511 option.WithConfig(brokenUserOAuthConfig(t, server.URL)), 1512 option.WithAuthToken("tok-x"), 1513 ) 1514 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 1515 t.Fatalf("expected request to succeed via WithAuthToken despite broken profile, got: %v", err) 1516 } 1517 if got := receivedHeaders.Get("Authorization"); got != "Bearer tok-x" { 1518 t.Fatalf("got Authorization %q, want %q", got, "Bearer tok-x") 1519 } 1520 } 1521 1522 // TestWithConfig_BaseURLAppliesEvenWithBrokenCreds verifies that the 1523 // profile's BaseURL is honored regardless of credential-resolution outcome — 1524 // it's applied at option-Apply time, not gated behind ResolveCredentials. 1525 func TestWithConfig_BaseURLAppliesEvenWithBrokenCreds(t *testing.T) { 1526 auth.ResetWarnOnceForTest() 1527 unsetEnv(t, "ANTHROPIC_API_KEY") 1528 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1529 isolateAuthEnv(t) 1530 1531 var hit atomic.Bool 1532 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1533 hit.Store(true) 1534 w.Header().Set("Content-Type", "application/json") 1535 w.Write([]byte(successResponse)) 1536 })) 1537 defer server.Close() 1538 1539 // No WithBaseURL — only the profile's BaseURL. 1540 client := anthropic.NewClient( 1541 option.WithConfig(brokenUserOAuthConfig(t, server.URL)), 1542 option.WithAPIKey("sk-x"), 1543 ) 1544 if _, err := client.Messages.New(context.Background(), defaultParams); err != nil { 1545 t.Fatalf("unexpected error: %v", err) 1546 } 1547 if !hit.Load() { 1548 t.Fatal("request did not reach the profile's BaseURL") 1549 } 1550 } 1551 1552 // TestWithConfig_BrokenProfileNoOverrideStillFails verifies the negative: 1553 // when no tier-1 credential preempts the profile, the deferred resolution 1554 // error surfaces on the first request. 1555 func TestWithConfig_BrokenProfileNoOverrideStillFails(t *testing.T) { 1556 unsetEnv(t, "ANTHROPIC_API_KEY") 1557 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1558 isolateAuthEnv(t) 1559 1560 client := anthropic.NewClient( 1561 option.WithConfig(brokenUserOAuthConfig(t, "http://127.0.0.1:1")), 1562 option.WithMaxRetries(0), 1563 ) 1564 _, err := client.Messages.New(context.Background(), defaultParams) 1565 if err == nil { 1566 t.Fatal("expected credential-resolution error to surface, got nil") 1567 } 1568 if !strings.Contains(err.Error(), "missing.json") && !strings.Contains(err.Error(), "credential") { 1569 t.Fatalf("expected the deferred resolve error, got: %v", err) 1570 } 1571 } 1572 1573 // TestDefaultClient_NoExplicitProfileFallsThrough verifies the full 1574 // no-credentials chain: no env API key, no ANTHROPIC_PROFILE, no profile 1575 // file on disk, no env federation — the SDK returns the aggregated 1576 // NoCredentialsError. 1577 func TestDefaultClient_NoExplicitProfileFallsThrough(t *testing.T) { 1578 unsetEnv(t, "ANTHROPIC_API_KEY") 1579 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1580 isolateAuthEnv(t) 1581 unsetEnv(t, "ANTHROPIC_PROFILE") 1582 1583 dir := t.TempDir() // no configs/ subdir 1584 t.Setenv("ANTHROPIC_CONFIG_DIR", dir) 1585 t.Setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:1") 1586 1587 client := anthropic.NewClient() 1588 _, err := client.Messages.New(context.Background(), defaultParams) 1589 if err == nil { 1590 t.Fatal("expected NoCredentialsError when nothing is set") 1591 } 1592 if !strings.Contains(err.Error(), "no Anthropic credentials") { 1593 t.Errorf("expected aggregated error, got: %v", err) 1594 } 1595 } 1596 1597 type recordedRequest struct { 1598 method string 1599 url string 1600 auth string 1601 } 1602 1603 type recordingHandler struct { 1604 mu sync.Mutex 1605 reqs []recordedRequest 1606 } 1607 1608 func (h *recordingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 1609 h.mu.Lock() 1610 h.reqs = append(h.reqs, recordedRequest{method: r.Method, url: r.URL.String(), auth: r.Header.Get("Authorization")}) 1611 h.mu.Unlock() 1612 w.Header().Set("Content-Type", "application/json") 1613 w.Write([]byte(`{}`)) 1614 } 1615 1616 func (h *recordingHandler) requests() []recordedRequest { 1617 h.mu.Lock() 1618 defer h.mu.Unlock() 1619 return append([]recordedRequest(nil), h.reqs...) 1620 } 1621 1622 func assertNoCredentials(t *testing.T, reqs []recordedRequest) { 1623 t.Helper() 1624 for _, r := range reqs { 1625 if strings.Contains(r.url, "/v1/oauth/token") { 1626 t.Errorf("token exchange was sent to a foreign origin: %s %s", r.method, r.url) 1627 } 1628 if r.auth != "" { 1629 t.Errorf("credential was sent to a foreign origin: %s %s Authorization=%q", r.method, r.url, r.auth) 1630 } 1631 } 1632 } 1633 1634 func federationServer(t *testing.T) (*httptest.Server, *recordingHandler, *atomic.Int32) { 1635 t.Helper() 1636 var exchanges atomic.Int32 1637 api := &recordingHandler{} 1638 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 1639 if r.URL.Path == "/v1/oauth/token" { 1640 exchanges.Add(1) 1641 w.Header().Set("Content-Type", "application/json") 1642 json.NewEncoder(w).Encode(map[string]any{"access_token": "exchanged-tok"}) 1643 return 1644 } 1645 api.ServeHTTP(w, r) 1646 })) 1647 t.Cleanup(server.Close) 1648 return server, api, &exchanges 1649 } 1650 1651 func TestIntegration_AbsoluteURLForeignHost_FederationProvider(t *testing.T) { 1652 unsetEnv(t, "ANTHROPIC_API_KEY") 1653 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1654 isolateAuthEnv(t) 1655 1656 base, baseAPI, exchanges := federationServer(t) 1657 attacker := &recordingHandler{} 1658 attackerServer := httptest.NewServer(attacker) 1659 defer attackerServer.Close() 1660 1661 var identityCalls atomic.Int32 1662 client := anthropic.NewClient( 1663 option.WithMaxRetries(0), 1664 option.WithBaseURL(base.URL), 1665 option.WithFederationTokenProvider( 1666 func(context.Context) (string, error) { 1667 identityCalls.Add(1) 1668 return "secret-jwt", nil 1669 }, 1670 option.FederationOptions{FederationRuleID: "rule-1", OrganizationID: "org-1"}, 1671 ), 1672 ) 1673 1674 err := client.Get(context.Background(), attackerServer.URL+"/v1/collect", nil, nil) 1675 assertNoCredentials(t, attacker.requests()) 1676 if err != nil { 1677 t.Fatalf("foreign-host request: %v", err) 1678 } 1679 if got := identityCalls.Load(); got != 0 { 1680 t.Errorf("identity token was fetched %d times for a foreign-host request, want 0", got) 1681 } 1682 if got := exchanges.Load(); got != 0 { 1683 t.Errorf("token exchange ran %d times for a foreign-host request, want 0", got) 1684 } 1685 1686 if err := client.Get(context.Background(), "/v1/models", nil, nil); err != nil { 1687 t.Fatalf("same-origin request: %v", err) 1688 } 1689 if got := exchanges.Load(); got != 1 { 1690 t.Fatalf("token exchange ran %d times at the configured base URL, want 1", got) 1691 } 1692 reqs := baseAPI.requests() 1693 if len(reqs) != 1 || reqs[0].auth != "Bearer exchanged-tok" { 1694 t.Fatalf("configured base URL got %+v, want one request with the exchanged bearer", reqs) 1695 } 1696 assertNoCredentials(t, attacker.requests()) 1697 } 1698 1699 // Same as above for a config-file profile with no base_url of its own. 1700 func TestIntegration_AbsoluteURLForeignHost_ProfileFederation(t *testing.T) { 1701 unsetEnv(t, "ANTHROPIC_API_KEY") 1702 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1703 isolateAuthEnv(t) 1704 1705 base, baseAPI, exchanges := federationServer(t) 1706 attacker := &recordingHandler{} 1707 attackerServer := httptest.NewServer(attacker) 1708 defer attackerServer.Close() 1709 1710 dir := t.TempDir() 1711 tokenPath := filepath.Join(dir, "identity-token") 1712 os.WriteFile(tokenPath, []byte("secret-jwt"), 0600) 1713 client := anthropic.NewClient( 1714 option.WithMaxRetries(0), 1715 option.WithBaseURL(base.URL), 1716 option.WithConfig(&config.Config{ 1717 OrganizationID: "org-1", 1718 AuthenticationInfo: &config.AuthenticationInfo{ 1719 Type: config.AuthenticationTypeOIDCFederation, 1720 CredentialsPath: filepath.Join(dir, "credentials", "creds.json"), 1721 OIDCFederation: &config.OIDCFederation{ 1722 FederationRuleID: "rule-1", 1723 IdentityToken: &config.IdentityTokenConfig{Source: config.IdentityTokenSourceFile, Path: tokenPath}, 1724 }, 1725 }, 1726 }), 1727 ) 1728 1729 err := client.Post(context.Background(), attackerServer.URL+"/v1/collect", nil, nil) 1730 assertNoCredentials(t, attacker.requests()) 1731 if err != nil { 1732 t.Fatalf("foreign-host request: %v", err) 1733 } 1734 if got := exchanges.Load(); got != 0 { 1735 t.Errorf("token exchange ran %d times for a foreign-host request, want 0", got) 1736 } 1737 1738 if err := client.Get(context.Background(), "/v1/models", nil, nil); err != nil { 1739 t.Fatalf("same-origin request: %v", err) 1740 } 1741 if got := exchanges.Load(); got != 1 { 1742 t.Fatalf("token exchange ran %d times at the configured base URL, want 1", got) 1743 } 1744 if reqs := baseAPI.requests(); len(reqs) != 1 || reqs[0].auth != "Bearer exchanged-tok" { 1745 t.Fatalf("configured base URL got %+v, want one request with the exchanged bearer", reqs) 1746 } 1747 assertNoCredentials(t, attacker.requests()) 1748 } 1749 1750 // recordingTransport records requests for origins no test server listens on. 1751 type recordingTransport struct { 1752 recordingHandler 1753 } 1754 1755 func (rt *recordingTransport) RoundTrip(r *http.Request) (*http.Response, error) { 1756 rec := httptest.NewRecorder() 1757 rt.ServeHTTP(rec, r) 1758 resp := rec.Result() 1759 resp.Request = r 1760 return resp, nil 1761 } 1762 1763 func TestIntegration_AbsoluteURLSchemeDowngrade_NoCredentials(t *testing.T) { 1764 unsetEnv(t, "ANTHROPIC_API_KEY") 1765 unsetEnv(t, "ANTHROPIC_AUTH_TOKEN") 1766 isolateAuthEnv(t) 1767 1768 credPath := filepath.Join(t.TempDir(), "credentials.json") 1769 os.WriteFile(credPath, []byte(`{"type":"oauth_token","access_token":"static-tok"}`), 0600) 1770 1771 transport := &recordingTransport{} 1772 client := anthropic.NewClient( 1773 option.WithHTTPClient(&http.Client{Transport: transport}), 1774 option.WithBaseURL("https://api.example.test"), 1775 option.WithConfig(&config.Config{ 1776 AuthenticationInfo: &config.AuthenticationInfo{ 1777 Type: config.AuthenticationTypeUserOAuth, 1778 CredentialsPath: credPath, 1779 UserOAuth: &config.UserOAuth{}, 1780 }, 1781 }), 1782 ) 1783 1784 if err := client.Get(context.Background(), "https://api.example.test/v1/models", nil, nil); err != nil { 1785 t.Fatalf("same-origin absolute URL: %v", err) 1786 } 1787 if reqs := transport.requests(); len(reqs) != 1 || reqs[0].auth != "Bearer static-tok" { 1788 t.Fatalf("same-origin absolute URL got %+v, want the bearer attached", reqs) 1789 } 1790 1791 if err := client.Get(context.Background(), "http://api.example.test/v1/models", nil, nil); err != nil { 1792 t.Fatalf("downgraded absolute URL: %v", err) 1793 } 1794 reqs := transport.requests() 1795 if len(reqs) != 2 { 1796 t.Fatalf("got %d requests, want 2", len(reqs)) 1797 } 1798 if reqs[1].auth != "" { 1799 t.Fatalf("bearer was attached to cleartext request %s: Authorization=%q", reqs[1].url, reqs[1].auth) 1800 } 1801 }