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  }