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 }