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 }