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 }