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

     1  package auth
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"io"
     8  	"net/http"
     9  	"net/http/httptest"
    10  	"strings"
    11  	"testing"
    12  )
    13  
    14  type staticIdentity struct{ token string }
    15  
    16  func (s *staticIdentity) GetIdentityToken(_ context.Context) (string, error) {
    17  	return s.token, nil
    18  }
    19  
    20  type identityFunc func(ctx context.Context) (string, error)
    21  
    22  func (f identityFunc) GetIdentityToken(ctx context.Context) (string, error) {
    23  	return f(ctx)
    24  }
    25  
    26  func TestWorkloadIdentityCredentials_Exchange(t *testing.T) {
    27  	var receivedBody tokenExchangeRequest
    28  	var receivedBetaHeader string
    29  
    30  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    31  		receivedBetaHeader = r.Header.Get("anthropic-beta")
    32  		if err := json.NewDecoder(r.Body).Decode(&receivedBody); err != nil {
    33  			t.Fatal(err)
    34  		}
    35  		expiresIn := 3600
    36  		json.NewEncoder(w).Encode(tokenExchangeResponse{
    37  			AccessToken: "access-tok-123",
    38  			ExpiresIn:   &expiresIn,
    39  		})
    40  	}))
    41  	defer server.Close()
    42  
    43  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
    44  		IdentityProvider: &staticIdentity{token: "my-jwt"},
    45  		FederationRuleID: "rule-1",
    46  		OrganizationID:   "org-1",
    47  		ServiceAccountID: "sa-1",
    48  		BaseURL:          server.URL,
    49  	})
    50  
    51  	token, err := provider(context.Background(), "", http.DefaultClient.Do)
    52  	if err != nil {
    53  		t.Fatal(err)
    54  	}
    55  	if token.Token != "access-tok-123" {
    56  		t.Fatalf("got %q, want %q", token.Token, "access-tok-123")
    57  	}
    58  	if token.ExpiresAt == nil {
    59  		t.Fatal("expected ExpiresAt to be set")
    60  	}
    61  	if !strings.Contains(receivedBetaHeader, FederationBetaHeader) {
    62  		t.Fatalf("got beta header %q, want to contain %q", receivedBetaHeader, FederationBetaHeader)
    63  	}
    64  	if !strings.Contains(receivedBetaHeader, OAuthAPIBetaHeader) {
    65  		t.Fatalf("got beta header %q, want to contain %q", receivedBetaHeader, OAuthAPIBetaHeader)
    66  	}
    67  	if receivedBody.GrantType != GrantTypeJWTBearer {
    68  		t.Fatalf("got grant_type %q, want %q", receivedBody.GrantType, GrantTypeJWTBearer)
    69  	}
    70  	if receivedBody.Assertion != "my-jwt" {
    71  		t.Fatalf("got assertion %q, want %q", receivedBody.Assertion, "my-jwt")
    72  	}
    73  	if receivedBody.FederationRuleID != "rule-1" {
    74  		t.Fatalf("got federation_rule_id %q, want %q", receivedBody.FederationRuleID, "rule-1")
    75  	}
    76  	if receivedBody.OrganizationID != "org-1" {
    77  		t.Fatalf("got organization_id %q, want %q", receivedBody.OrganizationID, "org-1")
    78  	}
    79  	if receivedBody.ServiceAccountID != "sa-1" {
    80  		t.Fatalf("got service_account_id %q, want %q", receivedBody.ServiceAccountID, "sa-1")
    81  	}
    82  }
    83  
    84  func TestWorkloadIdentityCredentials_OptionalFields(t *testing.T) {
    85  	var receivedRaw map[string]any
    86  
    87  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    88  		if err := json.NewDecoder(r.Body).Decode(&receivedRaw); err != nil {
    89  			t.Fatal(err)
    90  		}
    91  		json.NewEncoder(w).Encode(tokenExchangeResponse{AccessToken: "tok"})
    92  	}))
    93  	defer server.Close()
    94  
    95  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
    96  		IdentityProvider: &staticIdentity{token: "jwt"},
    97  		FederationRuleID: "rule-1",
    98  		OrganizationID:   "org-1",
    99  		BaseURL:          server.URL,
   100  	})
   101  
   102  	token, err := provider(context.Background(), "", http.DefaultClient.Do)
   103  	if err != nil {
   104  		t.Fatal(err)
   105  	}
   106  	if token.ExpiresAt != nil {
   107  		t.Fatal("expected ExpiresAt to be nil when expires_in is absent")
   108  	}
   109  	if _, present := receivedRaw["service_account_id"]; present {
   110  		t.Fatalf("service_account_id must be omitted when empty, body=%v", receivedRaw)
   111  	}
   112  	if _, present := receivedRaw["workspace_id"]; present {
   113  		t.Fatalf("workspace_id must be omitted when empty, body=%v", receivedRaw)
   114  	}
   115  }
   116  
   117  // TestWorkloadIdentityCredentials_WorkspaceIDIncluded verifies that an
   118  // explicitly configured WorkspaceID is sent as workspace_id in the
   119  // jwt-bearer exchange body so the server can mint a workspace-scoped token.
   120  func TestWorkloadIdentityCredentials_WorkspaceIDIncluded(t *testing.T) {
   121  	var receivedBody tokenExchangeRequest
   122  
   123  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   124  		if err := json.NewDecoder(r.Body).Decode(&receivedBody); err != nil {
   125  			t.Fatal(err)
   126  		}
   127  		json.NewEncoder(w).Encode(tokenExchangeResponse{AccessToken: "tok"})
   128  	}))
   129  	defer server.Close()
   130  
   131  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   132  		IdentityProvider: &staticIdentity{token: "jwt"},
   133  		FederationRuleID: "rule-1",
   134  		OrganizationID:   "org-1",
   135  		WorkspaceID:      "wrkspc_01abc",
   136  		BaseURL:          server.URL,
   137  	})
   138  
   139  	if _, err := provider(context.Background(), "", http.DefaultClient.Do); err != nil {
   140  		t.Fatal(err)
   141  	}
   142  	if receivedBody.WorkspaceID != "wrkspc_01abc" {
   143  		t.Fatalf("got workspace_id %q, want %q", receivedBody.WorkspaceID, "wrkspc_01abc")
   144  	}
   145  }
   146  
   147  // TestWorkloadIdentityCredentials_WorkspaceIDDefaultSentinel verifies the
   148  // literal "default" sentinel is passed through unchanged so callers can
   149  // pin the exchange to the organization's default workspace.
   150  func TestWorkloadIdentityCredentials_WorkspaceIDDefaultSentinel(t *testing.T) {
   151  	var receivedBody tokenExchangeRequest
   152  
   153  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   154  		if err := json.NewDecoder(r.Body).Decode(&receivedBody); err != nil {
   155  			t.Fatal(err)
   156  		}
   157  		json.NewEncoder(w).Encode(tokenExchangeResponse{AccessToken: "tok"})
   158  	}))
   159  	defer server.Close()
   160  
   161  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   162  		IdentityProvider: &staticIdentity{token: "jwt"},
   163  		FederationRuleID: "rule-1",
   164  		OrganizationID:   "org-1",
   165  		WorkspaceID:      "default",
   166  		BaseURL:          server.URL,
   167  	})
   168  
   169  	if _, err := provider(context.Background(), "", http.DefaultClient.Do); err != nil {
   170  		t.Fatal(err)
   171  	}
   172  	if receivedBody.WorkspaceID != "default" {
   173  		t.Fatalf("got workspace_id %q, want %q", receivedBody.WorkspaceID, "default")
   174  	}
   175  }
   176  
   177  func TestWorkloadIdentityCredentials_ReInvokesIdentityProvider(t *testing.T) {
   178  	calls := 0
   179  	identity := identityFunc(func(_ context.Context) (string, error) {
   180  		calls++
   181  		return "jwt-" + string(rune('0'+calls)), nil
   182  	})
   183  
   184  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   185  		json.NewEncoder(w).Encode(tokenExchangeResponse{AccessToken: "tok"})
   186  	}))
   187  	defer server.Close()
   188  
   189  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   190  		IdentityProvider: identity,
   191  		FederationRuleID: "rule-1",
   192  		OrganizationID:   "org-1",
   193  		BaseURL:          server.URL,
   194  	})
   195  
   196  	_, _ = provider(context.Background(), "", http.DefaultClient.Do)
   197  	_, _ = provider(context.Background(), "", http.DefaultClient.Do)
   198  
   199  	if calls != 2 {
   200  		t.Fatalf("expected 2 identity provider calls, got %d", calls)
   201  	}
   202  }
   203  
   204  // TestWorkloadIdentityCredentials_RejectsOversizedAssertion checks that an
   205  // identity-token payload over the 16 KiB cap is rejected before any request
   206  // goes out, so an over-large or attacker-crafted assertion can't be
   207  // shipped to the token endpoint at request rate.
   208  func TestWorkloadIdentityCredentials_RejectsOversizedAssertion(t *testing.T) {
   209  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   210  		t.Fatal("server should not be hit when assertion is over-sized")
   211  	}))
   212  	defer server.Close()
   213  
   214  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   215  		IdentityProvider: &staticIdentity{token: strings.Repeat("a", MaxAssertionSize+1)},
   216  		FederationRuleID: "rule-1",
   217  		OrganizationID:   "org-1",
   218  		BaseURL:          server.URL,
   219  	})
   220  
   221  	_, err := provider(context.Background(), "", http.DefaultClient.Do)
   222  	if err == nil {
   223  		t.Fatal("expected error for over-sized assertion")
   224  	}
   225  	if !strings.Contains(err.Error(), "16384") {
   226  		t.Errorf("expected size limit in error, got: %v", err)
   227  	}
   228  }
   229  
   230  // TestWorkloadIdentityCredentials_RejectsOversizedResponseBody checks that
   231  // a token-endpoint response over 1 MiB is rejected rather than read into
   232  // memory in full. Mirrors the Python SDK's bound.
   233  func TestWorkloadIdentityCredentials_RejectsOversizedResponseBody(t *testing.T) {
   234  	huge := strings.Repeat("x", 2<<20) // 2 MiB > 1 MiB cap
   235  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   236  		w.Header().Set("Content-Type", "application/json")
   237  		// Wrap in valid JSON so the parse doesn't fail before the read does.
   238  		io.WriteString(w, `{"access_token":"`+huge+`"}`)
   239  	}))
   240  	defer server.Close()
   241  
   242  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   243  		IdentityProvider: &staticIdentity{token: "jwt"},
   244  		FederationRuleID: "rule-1",
   245  		OrganizationID:   "org-1",
   246  		BaseURL:          server.URL,
   247  	})
   248  
   249  	_, err := provider(context.Background(), "", http.DefaultClient.Do)
   250  	if err == nil {
   251  		t.Fatal("expected parse error from truncated 2 MiB response")
   252  	}
   253  }
   254  
   255  // TestWorkloadIdentityCredentials_RejectsNonBearerTokenType checks that the
   256  // federation grant rejects a token_type other than Bearer (case-insensitive).
   257  // Python validates this; Go user_oauth refresh did too, but federation did not
   258  // until parity sweep.
   259  func TestWorkloadIdentityCredentials_RejectsNonBearerTokenType(t *testing.T) {
   260  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   261  		json.NewEncoder(w).Encode(tokenExchangeResponse{
   262  			AccessToken: "tok",
   263  			TokenType:   "Mac", // not Bearer
   264  		})
   265  	}))
   266  	defer server.Close()
   267  
   268  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   269  		IdentityProvider: &staticIdentity{token: "jwt"},
   270  		FederationRuleID: "rule-1",
   271  		OrganizationID:   "org-1",
   272  		BaseURL:          server.URL,
   273  	})
   274  
   275  	_, err := provider(context.Background(), "", http.DefaultClient.Do)
   276  	if err == nil {
   277  		t.Fatal("expected error for non-Bearer token_type")
   278  	}
   279  	if !strings.Contains(err.Error(), "Mac") || !strings.Contains(err.Error(), "Bearer") {
   280  		t.Errorf("expected token_type and Bearer in error, got: %v", err)
   281  	}
   282  }
   283  
   284  // TestWorkloadIdentityCredentials_SetsUserAgent verifies the federation
   285  // token POST carries an SDK User-Agent so server-side logs can attribute
   286  // the call to this SDK version.
   287  func TestWorkloadIdentityCredentials_SetsUserAgent(t *testing.T) {
   288  	var receivedUA string
   289  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   290  		receivedUA = r.Header.Get("User-Agent")
   291  		json.NewEncoder(w).Encode(tokenExchangeResponse{AccessToken: "tok"})
   292  	}))
   293  	defer server.Close()
   294  
   295  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   296  		IdentityProvider: &staticIdentity{token: "jwt"},
   297  		FederationRuleID: "rule-1",
   298  		OrganizationID:   "org-1",
   299  		BaseURL:          server.URL,
   300  	})
   301  
   302  	_, err := provider(context.Background(), "", http.DefaultClient.Do)
   303  	if err != nil {
   304  		t.Fatal(err)
   305  	}
   306  	if !strings.HasPrefix(receivedUA, "anthropic-sdk-go/") {
   307  		t.Errorf("got User-Agent %q, want prefix anthropic-sdk-go/", receivedUA)
   308  	}
   309  	if !strings.Contains(receivedUA, "oidc-federation") {
   310  		t.Errorf("got User-Agent %q, want context oidc-federation", receivedUA)
   311  	}
   312  }
   313  
   314  func TestWorkloadIdentityCredentials_ErrorStatus(t *testing.T) {
   315  	for _, status := range []int{403, 503} {
   316  		server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   317  			w.WriteHeader(status)
   318  			w.Write([]byte(`{"error": "bad"}`))
   319  		}))
   320  
   321  		provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   322  			IdentityProvider: &staticIdentity{token: "jwt"},
   323  			FederationRuleID: "rule-1",
   324  			OrganizationID:   "org-1",
   325  			BaseURL:          server.URL,
   326  		})
   327  
   328  		_, err := provider(context.Background(), "", http.DefaultClient.Do)
   329  		server.Close()
   330  
   331  		if err == nil {
   332  			t.Fatalf("expected error for status %d", status)
   333  		}
   334  		wierr, ok := err.(*OAuthTokenError)
   335  		if !ok {
   336  			t.Fatalf("expected *OAuthTokenError, got %T", err)
   337  		}
   338  		if wierr.StatusCode != status {
   339  			t.Fatalf("got status %d, want %d", wierr.StatusCode, status)
   340  		}
   341  	}
   342  }
   343  
   344  // errStatusServer starts a one-shot httptest server that replies with the
   345  // given status and an RFC 6749-shaped error body. The caller owns Close.
   346  func errStatusServer(t *testing.T, status int) *httptest.Server {
   347  	t.Helper()
   348  	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   349  		w.WriteHeader(status)
   350  		w.Write([]byte(`{"error": "unauthorized"}`))
   351  	}))
   352  }
   353  
   354  // TestWorkloadIdentityCredentials_401NoWorkspaceIDIncludesHint verifies that
   355  // a 401 with no WorkspaceID surfaces the full hint: the always-on federation
   356  // guidance and auth-event pointer, plus the workspace-scope clause — such a
   357  // 401 is most often a federation rule spanning multiple workspaces, and the
   358  // server can't pick one for us.
   359  func TestWorkloadIdentityCredentials_401NoWorkspaceIDIncludesHint(t *testing.T) {
   360  	server := errStatusServer(t, http.StatusUnauthorized)
   361  	defer server.Close()
   362  
   363  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   364  		IdentityProvider: &staticIdentity{token: "jwt"},
   365  		FederationRuleID: "rule-1",
   366  		OrganizationID:   "org-1",
   367  		BaseURL:          server.URL,
   368  	})
   369  
   370  	_, err := provider(context.Background(), "", http.DefaultClient.Do)
   371  	if err == nil {
   372  		t.Fatal("expected error for 401")
   373  	}
   374  	msg := err.Error()
   375  	if !strings.Contains(msg, "Ensure your federation rule matches your identity token") {
   376  		t.Errorf("expected hint to include federation-rule guidance, got: %s", msg)
   377  	}
   378  	if !strings.Contains(msg, "ANTHROPIC_WORKSPACE_ID") {
   379  		t.Errorf("expected hint to mention ANTHROPIC_WORKSPACE_ID, got: %s", msg)
   380  	}
   381  	if !strings.Contains(msg, "scoped to multiple workspaces") {
   382  		t.Errorf("expected hint to mention 'scoped to multiple workspaces', got: %s", msg)
   383  	}
   384  	if !strings.Contains(msg, "workspace_id") {
   385  		t.Errorf("expected hint to mention the 'workspace_id' config key, got: %s", msg)
   386  	}
   387  	// The hint must point at a type consumers can actually import — the
   388  	// public option.FederationOptions, not the internal OIDCFederationConfig.
   389  	if !strings.Contains(msg, "option.FederationOptions") {
   390  		t.Errorf("expected hint to reference option.FederationOptions, got: %s", msg)
   391  	}
   392  	if strings.Contains(msg, "OIDCFederationConfig") {
   393  		t.Errorf("hint must not reference internal OIDCFederationConfig, got: %s", msg)
   394  	}
   395  	if !strings.Contains(msg, "View your authentication events") {
   396  		t.Errorf("expected hint to point at the authentication-events page, got: %s", msg)
   397  	}
   398  	// The relogin suffix is user_oauth-only; it must never run into this hint.
   399  	if strings.Contains(msg, "ant auth login") {
   400  		t.Errorf("workload-identity 401 must not suggest interactive relogin, got: %s", msg)
   401  	}
   402  }
   403  
   404  // TestWorkloadIdentityCredentials_401WithWorkspaceIDOmitsWorkspaceHint
   405  // verifies the workspace-scope clause is suppressed when WorkspaceID is
   406  // already set: a 401 then has some other cause (revoked rule, expired
   407  // assertion, ...) and that clause is noise. The always-on federation
   408  // guidance and auth-event pointer still apply.
   409  func TestWorkloadIdentityCredentials_401WithWorkspaceIDOmitsWorkspaceHint(t *testing.T) {
   410  	server := errStatusServer(t, http.StatusUnauthorized)
   411  	defer server.Close()
   412  
   413  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   414  		IdentityProvider: &staticIdentity{token: "jwt"},
   415  		FederationRuleID: "rule-1",
   416  		OrganizationID:   "org-1",
   417  		WorkspaceID:      "wrkspc_x",
   418  		BaseURL:          server.URL,
   419  	})
   420  
   421  	_, err := provider(context.Background(), "", http.DefaultClient.Do)
   422  	if err == nil {
   423  		t.Fatal("expected error for 401")
   424  	}
   425  	msg := err.Error()
   426  	if !strings.Contains(msg, "Ensure your federation rule") {
   427  		t.Errorf("expected hint to include federation-rule guidance, got: %s", msg)
   428  	}
   429  	if !strings.Contains(msg, "View your authentication events") {
   430  		t.Errorf("expected hint to point at the authentication-events page, got: %s", msg)
   431  	}
   432  	if strings.Contains(msg, "ANTHROPIC_WORKSPACE_ID") {
   433  		t.Errorf("workspace-scope clause must be omitted when WorkspaceID is set, got: %s", msg)
   434  	}
   435  	if strings.Contains(msg, "scoped to multiple workspaces") {
   436  		t.Errorf("workspace-scope clause must be omitted when WorkspaceID is set, got: %s", msg)
   437  	}
   438  	if strings.Contains(msg, "option.FederationOptions") {
   439  		t.Errorf("workspace-scope clause must be omitted when WorkspaceID is set, got: %s", msg)
   440  	}
   441  }
   442  
   443  // TestWorkloadIdentityCredentials_NoReloginSuggestion verifies that
   444  // workload-identity token-exchange failures never suggest re-running
   445  // `ant auth login`. Machine credentials have no interactive browser
   446  // login to re-run; that remediation is only meaningful for the user_oauth
   447  // flow. The cases below are exactly the statuses (and the invalid_grant
   448  // RFC code) that trigger the relogin suffix on the user_oauth path — see
   449  // shouldSuggestRelogin and TestOAuthTokenError_*SuggestsRelogin.
   450  //
   451  // Only the 401 row also carries the workload-identity hint; with the relogin
   452  // suffix suppressed it must join directly onto the parsed RFC 6749 body as
   453  // `<body>. Ensure your federation rule...` and read as prose.
   454  func TestWorkloadIdentityCredentials_NoReloginSuggestion(t *testing.T) {
   455  	cases := []struct {
   456  		name     string
   457  		status   int
   458  		body     string
   459  		wantHint bool
   460  	}{
   461  		{"401_unauthorized", http.StatusUnauthorized, `{"error":"unauthorized"}`, true},
   462  		{"403_forbidden", http.StatusForbidden, `{"error":"access_denied"}`, false},
   463  		{"400_bad_request", http.StatusBadRequest, `{"error":"invalid_request"}`, false},
   464  		{"400_invalid_grant", http.StatusBadRequest, `{"error":"invalid_grant","error_description":"assertion expired"}`, false},
   465  	}
   466  	for _, tc := range cases {
   467  		t.Run(tc.name, func(t *testing.T) {
   468  			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   469  				w.WriteHeader(tc.status)
   470  				w.Write([]byte(tc.body))
   471  			}))
   472  			defer server.Close()
   473  
   474  			provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   475  				IdentityProvider: &staticIdentity{token: "jwt"},
   476  				FederationRuleID: "rule-1",
   477  				OrganizationID:   "org-1",
   478  				BaseURL:          server.URL,
   479  			})
   480  
   481  			_, err := provider(context.Background(), "", http.DefaultClient.Do)
   482  			if err == nil {
   483  				t.Fatalf("expected error for status %d", tc.status)
   484  			}
   485  			var oauthErr *OAuthTokenError
   486  			if !errors.As(err, &oauthErr) {
   487  				t.Fatalf("expected *OAuthTokenError, got %T: %v", err, err)
   488  			}
   489  			if !oauthErr.WorkloadIdentity {
   490  				t.Error("expected WorkloadIdentity to be set on workload-identity errors")
   491  			}
   492  			msg := err.Error()
   493  			if strings.Contains(msg, "ant auth login") {
   494  				t.Errorf("workload-identity error must not suggest interactive relogin, got: %s", msg)
   495  			}
   496  			if tc.wantHint {
   497  				if !strings.Contains(msg, ". Ensure your federation rule matches your identity token") {
   498  					t.Errorf("expected hint to follow the body with a period-space join, got: %s", msg)
   499  				}
   500  				if !strings.Contains(msg, "View your authentication events") {
   501  					t.Errorf("expected hint to point at the authentication-events page, got: %s", msg)
   502  				}
   503  			} else {
   504  				if strings.Contains(msg, "Ensure your federation rule") {
   505  					t.Errorf("hint must be omitted on non-401, got: %s", msg)
   506  				}
   507  				if strings.Contains(msg, "View your authentication events") {
   508  					t.Errorf("hint must be omitted on non-401, got: %s", msg)
   509  				}
   510  			}
   511  		})
   512  	}
   513  }
   514  
   515  // TestWorkloadIdentityCredentials_Non401NoWorkspaceIDOmitsHint verifies the
   516  // hint is 401-specific: a 5xx or a non-401 4xx shouldn't suggest a config
   517  // change.
   518  func TestWorkloadIdentityCredentials_Non401NoWorkspaceIDOmitsHint(t *testing.T) {
   519  	server := errStatusServer(t, http.StatusInternalServerError)
   520  	defer server.Close()
   521  
   522  	provider := NewOIDCFederationCredentials(OIDCFederationConfig{
   523  		IdentityProvider: &staticIdentity{token: "jwt"},
   524  		FederationRuleID: "rule-1",
   525  		OrganizationID:   "org-1",
   526  		BaseURL:          server.URL,
   527  	})
   528  
   529  	_, err := provider(context.Background(), "", http.DefaultClient.Do)
   530  	if err == nil {
   531  		t.Fatal("expected error for 500")
   532  	}
   533  	msg := err.Error()
   534  	if strings.Contains(msg, "Ensure your federation rule") {
   535  		t.Errorf("hint must be omitted on non-401, got: %s", msg)
   536  	}
   537  	if strings.Contains(msg, "View your authentication events") {
   538  		t.Errorf("hint must be omitted on non-401, got: %s", msg)
   539  	}
   540  	if strings.Contains(msg, "ANTHROPIC_WORKSPACE_ID") {
   541  		t.Errorf("hint must be omitted on non-401, got: %s", msg)
   542  	}
   543  }