github.com/anthropics/anthropic-sdk-go@v1.70.1/config/federation_test.go (about)

     1  package config_test
     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  	"github.com/anthropics/anthropic-sdk-go/config"
    14  )
    15  
    16  func TestExchangeFederationAssertion_Success(t *testing.T) {
    17  	var gotReq *http.Request
    18  	var gotBody []byte
    19  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    20  		gotReq = r.Clone(context.Background())
    21  		gotBody, _ = io.ReadAll(r.Body)
    22  		w.Header().Set("Content-Type", "application/json")
    23  		w.Header().Set("Request-Id", "req_success")
    24  		expiresIn := 3600
    25  		json.NewEncoder(w).Encode(map[string]any{
    26  			"access_token": "fed-access",
    27  			"token_type":   "Bearer",
    28  			"expires_in":   expiresIn,
    29  		})
    30  	}))
    31  	defer server.Close()
    32  
    33  	creds, err := config.ExchangeFederationAssertion(context.Background(), config.FederationExchangeParams{
    34  		Assertion:        "my-jwt",
    35  		FederationRuleID: "fdrl_abc",
    36  		OrganizationID:   "org_123",
    37  		ServiceAccountID: "svac_def",
    38  		WorkspaceID:      "wrkspc_x",
    39  		BaseURL:          server.URL,
    40  	})
    41  	if err != nil {
    42  		t.Fatal(err)
    43  	}
    44  	if creds.AccessToken != "fed-access" {
    45  		t.Errorf("AccessToken: %q", creds.AccessToken)
    46  	}
    47  	if creds.RefreshToken != "" {
    48  		t.Errorf("federation should not return refresh_token, got %q", creds.RefreshToken)
    49  	}
    50  	if creds.ExpiresAt == nil {
    51  		t.Error("ExpiresAt should be populated")
    52  	}
    53  
    54  	if gotReq.URL.Path != "/v1/oauth/token" {
    55  		t.Errorf("path: %q", gotReq.URL.Path)
    56  	}
    57  	if ct := gotReq.Header.Get("Content-Type"); ct != "application/json" {
    58  		t.Errorf("Content-Type: %q", ct)
    59  	}
    60  	beta := gotReq.Header.Get("anthropic-beta")
    61  	if !strings.Contains(beta, "oauth-2025-04-20") || !strings.Contains(beta, "oidc-federation-2026-04-01") {
    62  		t.Errorf("anthropic-beta header missing required values: %q", beta)
    63  	}
    64  
    65  	// The JSON body's field names must match the REST gateway's alias
    66  	// transformation exactly — grant_type, assertion, federation_rule_id,
    67  	// organization_id, service_account_id, workspace_id. Anything else is
    68  	// silently dropped by the gateway and produces an empty-field error
    69  	// downstream.
    70  	var decoded map[string]any
    71  	if err := json.Unmarshal(gotBody, &decoded); err != nil {
    72  		t.Fatalf("body must be JSON: %v, got=%q", err, gotBody)
    73  	}
    74  	want := map[string]any{
    75  		"grant_type":         "urn:ietf:params:oauth:grant-type:jwt-bearer",
    76  		"assertion":          "my-jwt",
    77  		"federation_rule_id": "fdrl_abc",
    78  		"organization_id":    "org_123",
    79  		"service_account_id": "svac_def",
    80  		"workspace_id":       "wrkspc_x",
    81  	}
    82  	for k, v := range want {
    83  		if decoded[k] != v {
    84  			t.Errorf("body[%q] = %v, want %v", k, decoded[k], v)
    85  		}
    86  	}
    87  	for _, forbidden := range []string{"federation_rule", "organization"} {
    88  		if _, present := decoded[forbidden]; present {
    89  			t.Errorf("body must not use alias %q, got %q", forbidden, gotBody)
    90  		}
    91  	}
    92  }
    93  
    94  func TestExchangeFederationAssertion_SendsDefaultUserAgent(t *testing.T) {
    95  	var gotUA string
    96  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    97  		gotUA = r.Header.Get("User-Agent")
    98  		json.NewEncoder(w).Encode(map[string]any{"access_token": "t"})
    99  	}))
   100  	defer server.Close()
   101  
   102  	if _, err := config.ExchangeFederationAssertion(context.Background(), config.FederationExchangeParams{
   103  		Assertion:        "jwt",
   104  		FederationRuleID: "fdrl_1",
   105  		OrganizationID:   "org_1",
   106  		BaseURL:          server.URL,
   107  	}); err != nil {
   108  		t.Fatal(err)
   109  	}
   110  	if !strings.HasPrefix(gotUA, "anthropic-sdk-go/") {
   111  		t.Errorf("default UA should start with anthropic-sdk-go/, got %q", gotUA)
   112  	}
   113  	if !strings.Contains(gotUA, "ExchangeFederationAssertion") {
   114  		t.Errorf("default UA should name the helper, got %q", gotUA)
   115  	}
   116  }
   117  
   118  func TestExchangeFederationAssertion_CustomUserAgent(t *testing.T) {
   119  	var gotUA string
   120  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   121  		gotUA = r.Header.Get("User-Agent")
   122  		json.NewEncoder(w).Encode(map[string]any{"access_token": "t"})
   123  	}))
   124  	defer server.Close()
   125  
   126  	if _, err := config.ExchangeFederationAssertion(context.Background(), config.FederationExchangeParams{
   127  		Assertion:        "jwt",
   128  		FederationRuleID: "fdrl_1",
   129  		OrganizationID:   "org_1",
   130  		BaseURL:          server.URL,
   131  		UserAgent:        "ant-cli/1.2.3",
   132  	}); err != nil {
   133  		t.Fatal(err)
   134  	}
   135  	if gotUA != "ant-cli/1.2.3" {
   136  		t.Errorf("custom UA not honoured, got %q", gotUA)
   137  	}
   138  }
   139  
   140  func TestExchangeFederationAssertion_OmitsServiceAccountWhenEmpty(t *testing.T) {
   141  	var gotBody []byte
   142  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   143  		gotBody, _ = io.ReadAll(r.Body)
   144  		json.NewEncoder(w).Encode(map[string]any{"access_token": "t"})
   145  	}))
   146  	defer server.Close()
   147  
   148  	if _, err := config.ExchangeFederationAssertion(context.Background(), config.FederationExchangeParams{
   149  		Assertion:        "jwt",
   150  		FederationRuleID: "fdrl_1",
   151  		OrganizationID:   "org_1",
   152  		BaseURL:          server.URL,
   153  	}); err != nil {
   154  		t.Fatal(err)
   155  	}
   156  	var decoded map[string]any
   157  	if err := json.Unmarshal(gotBody, &decoded); err != nil {
   158  		t.Fatal(err)
   159  	}
   160  	if _, present := decoded["service_account_id"]; present {
   161  		t.Errorf("service_account_id must be omitted when empty, body=%q", gotBody)
   162  	}
   163  	if _, present := decoded["workspace_id"]; present {
   164  		t.Errorf("workspace_id must be omitted when empty, body=%q", gotBody)
   165  	}
   166  }
   167  
   168  func TestExchangeFederationAssertion_ErrorSurfacesBodyAndRequestID(t *testing.T) {
   169  	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   170  		w.Header().Set("Request-Id", "req_oops")
   171  		w.WriteHeader(http.StatusBadRequest)
   172  		w.Write([]byte(`{"error":"invalid_grant","error_description":"assertion expired"}`))
   173  	}))
   174  	defer server.Close()
   175  
   176  	_, err := config.ExchangeFederationAssertion(context.Background(), config.FederationExchangeParams{
   177  		Assertion:        "jwt",
   178  		FederationRuleID: "fdrl_1",
   179  		OrganizationID:   "org_1",
   180  		BaseURL:          server.URL,
   181  	})
   182  	if err == nil {
   183  		t.Fatal("expected error")
   184  	}
   185  	var fe *config.FederationExchangeError
   186  	if !errors.As(err, &fe) {
   187  		t.Fatalf("expected *FederationExchangeError, got %T: %v", err, err)
   188  	}
   189  	if fe.StatusCode != http.StatusBadRequest {
   190  		t.Errorf("StatusCode: %d", fe.StatusCode)
   191  	}
   192  	if fe.RequestID != "req_oops" {
   193  		t.Errorf("RequestID: %q", fe.RequestID)
   194  	}
   195  	if !strings.Contains(fe.Body, "assertion expired") {
   196  		t.Errorf("Body missing server text: %q", fe.Body)
   197  	}
   198  	if !strings.Contains(fe.Error(), "req_oops") {
   199  		t.Errorf("Error() should include request id: %q", fe.Error())
   200  	}
   201  }
   202  
   203  func TestFederationExchangeError_RedactsSensitiveBody(t *testing.T) {
   204  	err := &config.FederationExchangeError{
   205  		StatusCode: 400,
   206  		RequestID:  "req_test",
   207  		Body:       `{"error":"invalid_request","error_description":"bad","assertion":"eyJhbGciOi...SECRET","refresh_token":"rt-secret","weird_new_field":"also-secret"}`,
   208  	}
   209  	msg := err.Error()
   210  	// Allowlist redaction (RFC 6749 §5.2): drop everything except error,
   211  	// error_description, error_uri. Future fields the server adds are
   212  	// dropped by default rather than relying on a denylist update.
   213  	for _, secret := range []string{"eyJhbGciOi", "SECRET", "rt-secret", "weird_new_field", "also-secret", "assertion", "refresh_token"} {
   214  		if strings.Contains(msg, secret) {
   215  			t.Errorf("federation error message leaks %q: %s", secret, msg)
   216  		}
   217  	}
   218  	if !strings.Contains(msg, `"error":"invalid_request"`) {
   219  		t.Errorf("expected RFC 6749 error key kept: %s", msg)
   220  	}
   221  	if !strings.Contains(msg, `"error_description":"bad"`) {
   222  		t.Errorf("expected RFC 6749 error_description kept: %s", msg)
   223  	}
   224  	if !strings.Contains(msg, "req_test") {
   225  		t.Errorf("expected request id in message: %s", msg)
   226  	}
   227  }
   228  
   229  func TestFederationExchangeError_TruncatesLongBody(t *testing.T) {
   230  	// Long JSON body containing an error key; allowlist preserves only that
   231  	// key and its (large) error_description value, which then truncates.
   232  	desc := strings.Repeat("x", config.OAuthErrorBodyMaxLen+200)
   233  	body := `{"error":"server_error","error_description":"` + desc + `"}`
   234  	err := &config.FederationExchangeError{StatusCode: 500, Body: body}
   235  	msg := err.Error()
   236  	if !strings.Contains(msg, "[truncated]") {
   237  		t.Errorf("expected truncation marker: %s", msg)
   238  	}
   239  }
   240  
   241  func TestFederationExchangeError_NonJSONBodyRedacted(t *testing.T) {
   242  	// Non-JSON bodies (e.g., HTML 5xx pages from a misconfigured proxy)
   243  	// must not be passed through to the user-facing error message.
   244  	err := &config.FederationExchangeError{StatusCode: 502, Body: "<html>upstream secret leaked here</html>"}
   245  	msg := err.Error()
   246  	if strings.Contains(msg, "secret") {
   247  		t.Errorf("non-JSON body must not pass through: %s", msg)
   248  	}
   249  	if !strings.Contains(msg, "[redacted; not a JSON error response]") {
   250  		t.Errorf("expected non-JSON redaction marker: %s", msg)
   251  	}
   252  }
   253  
   254  func TestExchangeFederationAssertion_ValidatesInputs(t *testing.T) {
   255  	ctx := context.Background()
   256  	cases := []struct {
   257  		name   string
   258  		params config.FederationExchangeParams
   259  	}{
   260  		{"missing assertion", config.FederationExchangeParams{FederationRuleID: "r", OrganizationID: "o"}},
   261  		{"missing rule", config.FederationExchangeParams{Assertion: "a", OrganizationID: "o"}},
   262  		{"missing org", config.FederationExchangeParams{Assertion: "a", FederationRuleID: "r"}},
   263  	}
   264  	for _, tc := range cases {
   265  		t.Run(tc.name, func(t *testing.T) {
   266  			if _, err := config.ExchangeFederationAssertion(ctx, tc.params); err == nil {
   267  				t.Error("expected error")
   268  			}
   269  		})
   270  	}
   271  }