github.com/anthropics/anthropic-sdk-go@v1.70.1/internal/auth/credentials_file_test.go (about)

     1  package auth
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"net/http"
     7  	"net/http/httptest"
     8  	"os"
     9  	"path/filepath"
    10  	"strings"
    11  	"testing"
    12  	"time"
    13  
    14  	"github.com/anthropics/anthropic-sdk-go/config"
    15  )
    16  
    17  func writeCredentials(t *testing.T, path string, data map[string]any) {
    18  	t.Helper()
    19  	os.MkdirAll(filepath.Dir(path), 0700)
    20  	b, _ := json.MarshalIndent(data, "", "  ")
    21  	os.WriteFile(path, b, 0600)
    22  }
    23  
    24  func TestResolveCredentials_OIDCFederation(t *testing.T) {
    25  	var receivedBody tokenExchangeRequest
    26  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    27  		if err := json.NewDecoder(r.Body).Decode(&receivedBody); err != nil {
    28  			t.Fatal(err)
    29  		}
    30  		expiresIn := 3600
    31  		json.NewEncoder(w).Encode(tokenExchangeResponse{
    32  			AccessToken: "exchanged-tok",
    33  			ExpiresIn:   &expiresIn,
    34  		})
    35  	}))
    36  	defer server.Close()
    37  
    38  	dir := t.TempDir()
    39  	tokenPath := filepath.Join(dir, "identity-token")
    40  	os.WriteFile(tokenPath, []byte("my-jwt"), 0600)
    41  
    42  	result, err := ResolveCredentials(&config.Config{
    43  		OrganizationID: "org-1",
    44  		WorkspaceID:    "wrkspc_x",
    45  		AuthenticationInfo: &config.AuthenticationInfo{
    46  			Type:            config.AuthenticationTypeOIDCFederation,
    47  			CredentialsPath: filepath.Join(dir, "credentials", "creds.json"),
    48  			OIDCFederation: &config.OIDCFederation{
    49  				FederationRuleID: "fdrl_1",
    50  				IdentityToken: &config.IdentityTokenConfig{
    51  					Source: config.IdentityTokenSourceFile,
    52  					Path:   tokenPath,
    53  				},
    54  			},
    55  		},
    56  	})
    57  	if err != nil {
    58  		t.Fatal(err)
    59  	}
    60  	// For federation profiles workspace_id is sent in the exchange body, not
    61  	// as a request header — the result must not surface a header value.
    62  	if result.WorkspaceID != "" {
    63  		t.Fatalf("federation result should not carry a header workspace_id, got %q", result.WorkspaceID)
    64  	}
    65  	tok, err := result.Provider(context.Background(), server.URL, http.DefaultClient.Do)
    66  	if err != nil {
    67  		t.Fatal(err)
    68  	}
    69  	if tok.Token != "exchanged-tok" {
    70  		t.Fatalf("got %q, want %q", tok.Token, "exchanged-tok")
    71  	}
    72  	if receivedBody.WorkspaceID != "wrkspc_x" {
    73  		t.Fatalf("got exchange-body workspace_id %q, want %q", receivedBody.WorkspaceID, "wrkspc_x")
    74  	}
    75  }
    76  
    77  func TestResolveCredentials_OIDCFederationWithCache(t *testing.T) {
    78  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    79  		t.Fatal("should not call token endpoint when cache is fresh")
    80  	}))
    81  	defer server.Close()
    82  
    83  	credPath := filepath.Join(t.TempDir(), "creds.json")
    84  	freshAt := time.Now().Add(10 * time.Minute).Unix()
    85  	writeCredentials(t, credPath, map[string]any{
    86  		"type":         "oauth_token",
    87  		"access_token": "cached-tok",
    88  		"expires_at":   freshAt,
    89  	})
    90  
    91  	result, err := ResolveCredentials(&config.Config{
    92  		OrganizationID: "org-1",
    93  		AuthenticationInfo: &config.AuthenticationInfo{
    94  			Type:            config.AuthenticationTypeOIDCFederation,
    95  			CredentialsPath: credPath,
    96  			OIDCFederation: &config.OIDCFederation{
    97  				FederationRuleID: "fdrl_1",
    98  				IdentityToken: &config.IdentityTokenConfig{
    99  					Source: config.IdentityTokenSourceFile,
   100  					Path:   "/dev/null",
   101  				},
   102  			},
   103  		},
   104  	})
   105  	if err != nil {
   106  		t.Fatal(err)
   107  	}
   108  
   109  	tok, err := result.Provider(context.Background(), server.URL, http.DefaultClient.Do)
   110  	if err != nil {
   111  		t.Fatal(err)
   112  	}
   113  	if tok.Token != "cached-tok" {
   114  		t.Fatalf("got %q, want %q", tok.Token, "cached-tok")
   115  	}
   116  }
   117  
   118  func TestResolveCredentials_OIDCFederationMissingOrganization(t *testing.T) {
   119  	dir := t.TempDir()
   120  	tokenPath := filepath.Join(dir, "token")
   121  	os.WriteFile(tokenPath, []byte("jwt"), 0600)
   122  
   123  	_, err := ResolveCredentials(&config.Config{
   124  		// missing organization_id
   125  		AuthenticationInfo: &config.AuthenticationInfo{
   126  			Type:            config.AuthenticationTypeOIDCFederation,
   127  			CredentialsPath: filepath.Join(dir, "creds.json"),
   128  			OIDCFederation: &config.OIDCFederation{
   129  				FederationRuleID: "fdrl_1",
   130  				IdentityToken: &config.IdentityTokenConfig{
   131  					Source: config.IdentityTokenSourceFile,
   132  					Path:   tokenPath,
   133  				},
   134  			},
   135  		},
   136  	})
   137  	if err == nil {
   138  		t.Fatal("expected error")
   139  	}
   140  }
   141  
   142  func TestResolveCredentials_OIDCFederationMissingFederationRuleID(t *testing.T) {
   143  	dir := t.TempDir()
   144  	tokenPath := filepath.Join(dir, "token")
   145  	os.WriteFile(tokenPath, []byte("jwt"), 0600)
   146  
   147  	_, err := ResolveCredentials(&config.Config{
   148  		OrganizationID: "org-1",
   149  		AuthenticationInfo: &config.AuthenticationInfo{
   150  			Type:            config.AuthenticationTypeOIDCFederation,
   151  			CredentialsPath: filepath.Join(dir, "creds.json"),
   152  			OIDCFederation: &config.OIDCFederation{
   153  				// missing federation_rule_id
   154  				IdentityToken: &config.IdentityTokenConfig{
   155  					Source: config.IdentityTokenSourceFile,
   156  					Path:   tokenPath,
   157  				},
   158  			},
   159  		},
   160  	})
   161  	if err == nil {
   162  		t.Fatal("expected error")
   163  	}
   164  }
   165  
   166  func TestResolveCredentials_UserOAuthRefresh(t *testing.T) {
   167  	refreshCalls := 0
   168  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   169  		refreshCalls++
   170  		beta := r.Header.Get("anthropic-beta")
   171  		if strings.Contains(beta, "oidc-federation-2026-04-01") {
   172  			t.Errorf("refresh request must not carry oidc-federation beta header, got %q", beta)
   173  		}
   174  		if !strings.Contains(beta, "oauth-2025-04-20") {
   175  			t.Errorf("refresh request missing required oauth beta header, got %q", beta)
   176  		}
   177  		var body map[string]string
   178  		json.NewDecoder(r.Body).Decode(&body)
   179  		if _, ok := body["client_secret"]; ok {
   180  			t.Fatal("client_secret should not be sent in refresh request")
   181  		}
   182  		expiresIn := 3600
   183  		json.NewEncoder(w).Encode(map[string]any{
   184  			"access_token":  "refreshed-tok",
   185  			"refresh_token": "new-refresh-tok",
   186  			"expires_in":    expiresIn,
   187  		})
   188  	}))
   189  	defer server.Close()
   190  
   191  	credPath := filepath.Join(t.TempDir(), "creds.json")
   192  	expiredAt := time.Now().Add(-1 * time.Minute).Unix()
   193  	writeCredentials(t, credPath, map[string]any{
   194  		"type":          "oauth_token",
   195  		"access_token":  "old-tok",
   196  		"expires_at":    expiredAt,
   197  		"refresh_token": "old-refresh",
   198  	})
   199  
   200  	result, err := ResolveCredentials(&config.Config{
   201  		AuthenticationInfo: &config.AuthenticationInfo{
   202  			Type:            config.AuthenticationTypeUserOAuth,
   203  			CredentialsPath: credPath,
   204  			UserOAuth: &config.UserOAuth{
   205  				ClientID: "my-client",
   206  			},
   207  		},
   208  	})
   209  	if err != nil {
   210  		t.Fatal(err)
   211  	}
   212  	tok, err := result.Provider(context.Background(), server.URL, http.DefaultClient.Do)
   213  	if err != nil {
   214  		t.Fatal(err)
   215  	}
   216  	if tok.Token != "refreshed-tok" {
   217  		t.Fatalf("got %q, want %q", tok.Token, "refreshed-tok")
   218  	}
   219  	if refreshCalls != 1 {
   220  		t.Fatalf("expected 1 refresh call, got %d", refreshCalls)
   221  	}
   222  
   223  	// Verify the credentials file was updated.
   224  	data, _ := os.ReadFile(credPath)
   225  	var updated credentialsTokenData
   226  	json.Unmarshal(data, &updated)
   227  	if updated.RefreshToken != "new-refresh-tok" {
   228  		t.Fatalf("credentials refresh_token not updated, got %q", updated.RefreshToken)
   229  	}
   230  	if updated.Type != "oauth_token" {
   231  		t.Fatalf("credentials type not set, got %q", updated.Type)
   232  	}
   233  }
   234  
   235  func TestResolveCredentials_UserOAuthFreshNoRefresh(t *testing.T) {
   236  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   237  		t.Fatal("should not call token endpoint")
   238  	}))
   239  	defer server.Close()
   240  
   241  	credPath := filepath.Join(t.TempDir(), "creds.json")
   242  	freshAt := time.Now().Add(10 * time.Minute).Unix()
   243  	writeCredentials(t, credPath, map[string]any{
   244  		"type":          "oauth_token",
   245  		"access_token":  "fresh-tok",
   246  		"expires_at":    freshAt,
   247  		"refresh_token": "refresh",
   248  	})
   249  
   250  	result, err := ResolveCredentials(&config.Config{
   251  		AuthenticationInfo: &config.AuthenticationInfo{
   252  			Type:            config.AuthenticationTypeUserOAuth,
   253  			CredentialsPath: credPath,
   254  			UserOAuth: &config.UserOAuth{
   255  				ClientID: "my-client",
   256  			},
   257  		},
   258  	})
   259  	if err != nil {
   260  		t.Fatal(err)
   261  	}
   262  
   263  	tok, err := result.Provider(context.Background(), server.URL, http.DefaultClient.Do)
   264  	if err != nil {
   265  		t.Fatal(err)
   266  	}
   267  	if tok.Token != "fresh-tok" {
   268  		t.Fatalf("got %q, want %q", tok.Token, "fresh-tok")
   269  	}
   270  }
   271  
   272  func TestResolveCredentials_UserOAuthNoClientIDStaticToken(t *testing.T) {
   273  	credPath := filepath.Join(t.TempDir(), "creds.json")
   274  	writeCredentials(t, credPath, map[string]any{
   275  		"type":         "oauth_token",
   276  		"access_token": "static-tok",
   277  	})
   278  
   279  	result, err := ResolveCredentials(&config.Config{
   280  		AuthenticationInfo: &config.AuthenticationInfo{
   281  			Type:            config.AuthenticationTypeUserOAuth,
   282  			CredentialsPath: credPath,
   283  			UserOAuth:       &config.UserOAuth{},
   284  		},
   285  	})
   286  	if err != nil {
   287  		t.Fatal(err)
   288  	}
   289  
   290  	tok, err := result.Provider(context.Background(), "", nil)
   291  	if err != nil {
   292  		t.Fatal(err)
   293  	}
   294  	if tok.Token != "static-tok" {
   295  		t.Fatalf("got %q, want %q", tok.Token, "static-tok")
   296  	}
   297  }
   298  
   299  // TestResolveCredentials_UserOAuthNoClientIDReReads verifies that the static
   300  // (no-client-id) user_oauth path re-reads the credentials file on every
   301  // provider call so externally-rotated tokens (e.g. from a credential daemon)
   302  // are picked up. This is the same behaviour the deleted "external" loader
   303  // provided.
   304  func TestResolveCredentials_UserOAuthNoClientIDReReads(t *testing.T) {
   305  	credPath := filepath.Join(t.TempDir(), "creds.json")
   306  	writeCredentials(t, credPath, map[string]any{
   307  		"type":         "oauth_token",
   308  		"access_token": "tok-1",
   309  	})
   310  
   311  	result, err := ResolveCredentials(&config.Config{
   312  		AuthenticationInfo: &config.AuthenticationInfo{
   313  			Type:            config.AuthenticationTypeUserOAuth,
   314  			CredentialsPath: credPath,
   315  			UserOAuth:       &config.UserOAuth{},
   316  		},
   317  	})
   318  	if err != nil {
   319  		t.Fatal(err)
   320  	}
   321  
   322  	tok, err := result.Provider(context.Background(), "", nil)
   323  	if err != nil {
   324  		t.Fatal(err)
   325  	}
   326  	if tok.Token != "tok-1" {
   327  		t.Fatalf("got %q, want %q", tok.Token, "tok-1")
   328  	}
   329  
   330  	// Rotate the file underneath us and verify the next call sees it.
   331  	writeCredentials(t, credPath, map[string]any{
   332  		"type":         "oauth_token",
   333  		"access_token": "tok-2",
   334  	})
   335  
   336  	tok, err = result.Provider(context.Background(), "", nil)
   337  	if err != nil {
   338  		t.Fatal(err)
   339  	}
   340  	if tok.Token != "tok-2" {
   341  		t.Fatalf("got %q, want %q", tok.Token, "tok-2")
   342  	}
   343  }
   344  
   345  // TestResolveCredentials_UserOAuthNoClientIDExpiredFailsFast verifies that
   346  // when a user_oauth profile has no client_id (no refresh possible) and the
   347  // on-disk token is already expired, ResolveCredentials returns an error
   348  // upfront instead of handing an expired token back to the cache, which
   349  // would otherwise 401-loop until maxRetries.
   350  func TestResolveCredentials_UserOAuthNoClientIDExpiredFailsFast(t *testing.T) {
   351  	credPath := filepath.Join(t.TempDir(), "creds.json")
   352  	expiredAt := time.Now().Add(-time.Hour).Unix()
   353  	writeCredentials(t, credPath, map[string]any{
   354  		"type":         "oauth_token",
   355  		"access_token": "stale-tok",
   356  		"expires_at":   expiredAt,
   357  	})
   358  
   359  	_, err := ResolveCredentials(&config.Config{
   360  		AuthenticationInfo: &config.AuthenticationInfo{
   361  			Type:            config.AuthenticationTypeUserOAuth,
   362  			CredentialsPath: credPath,
   363  			UserOAuth:       &config.UserOAuth{},
   364  		},
   365  	})
   366  	if err == nil {
   367  		t.Fatal("expected error for expired static user_oauth token, got nil")
   368  	}
   369  	if !strings.Contains(err.Error(), "expired") {
   370  		t.Errorf("error should mention expiry, got: %v", err)
   371  	}
   372  }
   373  
   374  // TestResolveCredentials_OIDCFederationEnvIdentityToken verifies that a
   375  // profile which omits identity_token falls back to ANTHROPIC_IDENTITY_TOKEN
   376  // (the literal token env var) in addition to the _FILE variant.
   377  func TestResolveCredentials_OIDCFederationEnvIdentityToken(t *testing.T) {
   378  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   379  		expiresIn := 3600
   380  		json.NewEncoder(w).Encode(tokenExchangeResponse{
   381  			AccessToken: "exchanged-from-env-literal",
   382  			ExpiresIn:   &expiresIn,
   383  		})
   384  	}))
   385  	defer server.Close()
   386  
   387  	t.Setenv(EnvIdentityTokenFile, "")
   388  	os.Unsetenv(EnvIdentityTokenFile)
   389  	t.Setenv(EnvIdentityToken, "literal-jwt-xyz")
   390  
   391  	dir := t.TempDir()
   392  	result, err := ResolveCredentials(&config.Config{
   393  		OrganizationID: "org-1",
   394  		AuthenticationInfo: &config.AuthenticationInfo{
   395  			Type:            config.AuthenticationTypeOIDCFederation,
   396  			CredentialsPath: filepath.Join(dir, "credentials", "creds.json"),
   397  			OIDCFederation: &config.OIDCFederation{
   398  				FederationRuleID: "fdrl_1",
   399  			},
   400  		},
   401  	})
   402  	if err != nil {
   403  		t.Fatalf("expected env-literal fallback to succeed, got: %v", err)
   404  	}
   405  	tok, err := result.Provider(context.Background(), server.URL, http.DefaultClient.Do)
   406  	if err != nil {
   407  		t.Fatal(err)
   408  	}
   409  	if tok.Token != "exchanged-from-env-literal" {
   410  		t.Fatalf("unexpected token: %q", tok.Token)
   411  	}
   412  }
   413  
   414  func TestResolveCredentials_BaseURLFromConfig(t *testing.T) {
   415  	credPath := filepath.Join(t.TempDir(), "creds.json")
   416  	writeCredentials(t, credPath, map[string]any{
   417  		"type":         "oauth_token",
   418  		"access_token": "tok",
   419  	})
   420  
   421  	result, err := ResolveCredentials(&config.Config{
   422  		BaseURL: "https://staging.anthropic.com",
   423  		AuthenticationInfo: &config.AuthenticationInfo{
   424  			Type:            config.AuthenticationTypeUserOAuth,
   425  			CredentialsPath: credPath,
   426  			UserOAuth:       &config.UserOAuth{},
   427  		},
   428  	})
   429  	if err != nil {
   430  		t.Fatal(err)
   431  	}
   432  	if result.BaseURL != "https://staging.anthropic.com" {
   433  		t.Fatalf("got base_url %q, want %q", result.BaseURL, "https://staging.anthropic.com")
   434  	}
   435  }
   436  
   437  func TestResolveCredentials_WorkspaceID(t *testing.T) {
   438  	credPath := filepath.Join(t.TempDir(), "creds.json")
   439  	writeCredentials(t, credPath, map[string]any{
   440  		"type":         "oauth_token",
   441  		"access_token": "tok",
   442  	})
   443  
   444  	result, err := ResolveCredentials(&config.Config{
   445  		WorkspaceID: "wrkspc_01test",
   446  		AuthenticationInfo: &config.AuthenticationInfo{
   447  			Type:            config.AuthenticationTypeUserOAuth,
   448  			CredentialsPath: credPath,
   449  			UserOAuth:       &config.UserOAuth{},
   450  		},
   451  	})
   452  	if err != nil {
   453  		t.Fatal(err)
   454  	}
   455  	if result.WorkspaceID != "wrkspc_01test" {
   456  		t.Fatalf("got workspace_id %q, want %q", result.WorkspaceID, "wrkspc_01test")
   457  	}
   458  }
   459  
   460  func TestResolveCredentials_MissingAuthenticationInfo(t *testing.T) {
   461  	_, err := ResolveCredentials(&config.Config{})
   462  	if err == nil {
   463  		t.Fatal("expected error for missing authentication")
   464  	}
   465  }
   466  
   467  func TestResolveCredentials_UnknownType(t *testing.T) {
   468  	_, err := ResolveCredentials(&config.Config{
   469  		AuthenticationInfo: &config.AuthenticationInfo{
   470  			Type: "unknown_type",
   471  		},
   472  	})
   473  	if err == nil {
   474  		t.Fatal("expected error for unknown type")
   475  	}
   476  }
   477  
   478  // TestCheckCredentialsFileSafety_RejectsWorldReadable verifies that a
   479  // credentials file with world-readable mode is refused. A bearer token
   480  // must not be readable by another UID on the host.
   481  func TestCheckCredentialsFileSafety_RejectsWorldReadable(t *testing.T) {
   482  	credPath := filepath.Join(t.TempDir(), "creds.json")
   483  	writeCredentials(t, credPath, map[string]any{"type": "oauth_token", "access_token": "tok"})
   484  	if err := os.Chmod(credPath, 0644); err != nil {
   485  		t.Fatal(err)
   486  	}
   487  	_, err := readCredentialsFile(credPath)
   488  	if err == nil {
   489  		t.Fatal("expected error for world-readable credentials file")
   490  	}
   491  	if !strings.Contains(err.Error(), "unsafe permissions") {
   492  		t.Errorf("expected unsafe-permissions error, got: %v", err)
   493  	}
   494  }
   495  
   496  // TestCheckCredentialsFileSafety_RejectsGroupReadable verifies that a
   497  // credentials file with group-readable mode is also refused (matches TS
   498  // behavior; Python warns but Go is stricter to match TS).
   499  func TestCheckCredentialsFileSafety_RejectsGroupReadable(t *testing.T) {
   500  	credPath := filepath.Join(t.TempDir(), "creds.json")
   501  	writeCredentials(t, credPath, map[string]any{"type": "oauth_token", "access_token": "tok"})
   502  	if err := os.Chmod(credPath, 0640); err != nil {
   503  		t.Fatal(err)
   504  	}
   505  	_, err := readCredentialsFile(credPath)
   506  	if err == nil {
   507  		t.Fatal("expected error for group-readable credentials file")
   508  	}
   509  }
   510  
   511  // TestCheckCredentialsFileSafety_RejectsWorldWritable verifies that a
   512  // credentials file with world-writable mode is refused. A writable file
   513  // lets an attacker inject a token the SDK then presents as the caller's
   514  // identity — at least as dangerous as a readable one.
   515  func TestCheckCredentialsFileSafety_RejectsWorldWritable(t *testing.T) {
   516  	credPath := filepath.Join(t.TempDir(), "creds.json")
   517  	writeCredentials(t, credPath, map[string]any{"type": "oauth_token", "access_token": "tok"})
   518  	if err := os.Chmod(credPath, 0602); err != nil {
   519  		t.Fatal(err)
   520  	}
   521  	_, err := readCredentialsFile(credPath)
   522  	if err == nil {
   523  		t.Fatal("expected error for world-writable credentials file")
   524  	}
   525  	if !strings.Contains(err.Error(), "unsafe permissions") {
   526  		t.Errorf("expected unsafe-permissions error, got: %v", err)
   527  	}
   528  }
   529  
   530  // TestCheckCredentialsFileSafety_AcceptsOwnerOnly verifies that 0600
   531  // credentials read normally — the safety check is opt-in to risky modes.
   532  func TestCheckCredentialsFileSafety_AcceptsOwnerOnly(t *testing.T) {
   533  	credPath := filepath.Join(t.TempDir(), "creds.json")
   534  	writeCredentials(t, credPath, map[string]any{"type": "oauth_token", "access_token": "tok"})
   535  	if err := os.Chmod(credPath, 0600); err != nil {
   536  		t.Fatal(err)
   537  	}
   538  	cred, err := readCredentialsFile(credPath)
   539  	if err != nil {
   540  		t.Fatalf("expected 0600 file to read cleanly, got: %v", err)
   541  	}
   542  	if cred.AccessToken != "tok" {
   543  		t.Errorf("got access_token %q, want %q", cred.AccessToken, "tok")
   544  	}
   545  }
   546  
   547  // TestCheckCredentialsFileSafety_RejectsSymlink verifies that a symlink
   548  // at the credentials path is refused. An attacker-controlled symlink in
   549  // the credentials dir could redirect a refresh-write to an arbitrary
   550  // location.
   551  func TestCheckCredentialsFileSafety_RejectsSymlink(t *testing.T) {
   552  	dir := t.TempDir()
   553  	realPath := filepath.Join(dir, "real.json")
   554  	writeCredentials(t, realPath, map[string]any{"type": "oauth_token", "access_token": "tok"})
   555  	if err := os.Chmod(realPath, 0600); err != nil {
   556  		t.Fatal(err)
   557  	}
   558  	linkPath := filepath.Join(dir, "creds.json")
   559  	if err := os.Symlink(realPath, linkPath); err != nil {
   560  		t.Skipf("symlink unsupported on this platform: %v", err)
   561  	}
   562  	_, err := readCredentialsFile(linkPath)
   563  	if err == nil {
   564  		t.Fatal("expected error for symlink at credentials path")
   565  	}
   566  	if !strings.Contains(err.Error(), "symlink") {
   567  		t.Errorf("expected symlink error, got: %v", err)
   568  	}
   569  }
   570  
   571  // TestResolveCredentials_UserOAuthRefresh_SetsUserAgent verifies that the
   572  // refresh-token POST carries an SDK User-Agent header. Mirrors the public
   573  // ExchangeFederationAssertion behavior; previously absent on the internal
   574  // refresh path.
   575  func TestResolveCredentials_UserOAuthRefresh_SetsUserAgent(t *testing.T) {
   576  	var receivedUA string
   577  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   578  		receivedUA = r.Header.Get("User-Agent")
   579  		expiresIn := 3600
   580  		json.NewEncoder(w).Encode(map[string]any{
   581  			"access_token":  "new-tok",
   582  			"refresh_token": "new-rt",
   583  			"expires_in":    expiresIn,
   584  		})
   585  	}))
   586  	defer server.Close()
   587  
   588  	credPath := filepath.Join(t.TempDir(), "creds.json")
   589  	expired := time.Now().Add(-time.Hour).Unix()
   590  	writeCredentials(t, credPath, map[string]any{
   591  		"type":          "oauth_token",
   592  		"access_token":  "stale",
   593  		"refresh_token": "rt",
   594  		"expires_at":    expired,
   595  	})
   596  	if err := os.Chmod(credPath, 0600); err != nil {
   597  		t.Fatal(err)
   598  	}
   599  
   600  	result, err := ResolveCredentials(&config.Config{
   601  		BaseURL: server.URL,
   602  		AuthenticationInfo: &config.AuthenticationInfo{
   603  			Type:            config.AuthenticationTypeUserOAuth,
   604  			CredentialsPath: credPath,
   605  			UserOAuth:       &config.UserOAuth{ClientID: "cid"},
   606  		},
   607  	})
   608  	if err != nil {
   609  		t.Fatal(err)
   610  	}
   611  	if _, err := result.Provider(context.Background(), "", http.DefaultClient.Do); err != nil {
   612  		t.Fatal(err)
   613  	}
   614  	if !strings.HasPrefix(receivedUA, "anthropic-sdk-go/") {
   615  		t.Errorf("got User-Agent %q, want prefix anthropic-sdk-go/", receivedUA)
   616  	}
   617  	if !strings.Contains(receivedUA, "user-oauth-refresh") {
   618  		t.Errorf("got User-Agent %q, want context user-oauth-refresh", receivedUA)
   619  	}
   620  }