github.com/anthropics/anthropic-sdk-go@v1.70.1/internal/auth/workload.go (about) 1 package auth 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "fmt" 8 "io" 9 "net/http" 10 "strings" 11 "time" 12 13 "github.com/anthropics/anthropic-sdk-go/internal" 14 ) 15 16 // MaxAssertionSize bounds the JWT sent to /v1/oauth/token. Honest OIDC 17 // tokens are well under 16 KiB; reject larger inputs before shipping them 18 // at request rate. 19 const MaxAssertionSize = 16384 20 21 // OIDCFederationConfig configures an OIDC-federation [TokenProvider] that 22 // exchanges a third-party JWT for an Anthropic access token. 23 type OIDCFederationConfig struct { 24 IdentityProvider IdentityTokenProvider 25 FederationRuleID string 26 OrganizationID string 27 ServiceAccountID string // optional 28 // WorkspaceID is an optional `wrkspc_*` tagged ID, or the literal 29 // "default" to scope the token to the organization's default workspace. 30 // When omitted the server picks the rule's sole enabled workspace, else 31 // the org default if the rule covers it. Required when the rule enables 32 // more than one non-default workspace, or to target a specific workspace 33 // other than the one the server would pick. The minted token is 34 // workspace-scoped: per-request workspace selection (the 35 // anthropic-workspace-id header) is not supported for federation 36 // tokens — switching workspaces requires a new token exchange with a 37 // different WorkspaceID. 38 WorkspaceID string 39 BaseURL string // optional override; if empty, uses baseURL from TokenCache 40 } 41 42 type tokenExchangeRequest struct { 43 GrantType string `json:"grant_type"` 44 Assertion string `json:"assertion"` 45 FederationRuleID string `json:"federation_rule_id"` 46 OrganizationID string `json:"organization_id"` 47 ServiceAccountID string `json:"service_account_id,omitempty"` 48 WorkspaceID string `json:"workspace_id,omitempty"` 49 } 50 51 type tokenExchangeResponse struct { 52 AccessToken string `json:"access_token"` 53 TokenType string `json:"token_type,omitempty"` 54 ExpiresIn *int `json:"expires_in,omitempty"` 55 } 56 57 // NewOIDCFederationCredentials returns a [TokenProvider] that exchanges an 58 // OIDC identity token for a short-lived Anthropic access token via the OAuth 59 // token endpoint. 60 func NewOIDCFederationCredentials(cfg OIDCFederationConfig) TokenProvider { 61 return func(ctx context.Context, baseURL string, handler func(*http.Request) (*http.Response, error)) (*AccessToken, error) { 62 effectiveBase := strings.TrimRight(cfg.BaseURL, "/") 63 if effectiveBase == "" { 64 effectiveBase = strings.TrimRight(baseURL, "/") 65 } 66 if err := requireSecureTokenEndpoint(effectiveBase); err != nil { 67 return nil, err 68 } 69 jwt, err := cfg.IdentityProvider.GetIdentityToken(ctx) 70 if err != nil { 71 return nil, fmt.Errorf("failed to get identity token: %w", err) 72 } 73 // post-fetch: bounds wire size, not allocation 74 if len(jwt) > MaxAssertionSize { 75 return nil, fmt.Errorf("identity token exceeds %d-byte limit (got %d bytes)", MaxAssertionSize, len(jwt)) 76 } 77 78 body := tokenExchangeRequest{ 79 GrantType: GrantTypeJWTBearer, 80 Assertion: jwt, 81 FederationRuleID: cfg.FederationRuleID, 82 OrganizationID: cfg.OrganizationID, 83 ServiceAccountID: cfg.ServiceAccountID, 84 WorkspaceID: cfg.WorkspaceID, 85 } 86 bodyJSON, err := json.Marshal(body) 87 if err != nil { 88 return nil, fmt.Errorf("failed to marshal token exchange request: %w", err) 89 } 90 91 endpoint := effectiveBase + TokenEndpoint 92 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON)) 93 if err != nil { 94 return nil, fmt.Errorf("failed to create token exchange request: %w", err) 95 } 96 req.Header.Set("Content-Type", "application/json") 97 req.Header.Set("User-Agent", "anthropic-sdk-go/"+internal.PackageVersion+" (oidc-federation)") 98 // oauth-2025-04-20 unlocks the oauth/token endpoint family and is 99 // required alongside the federation beta for jwt-bearer grants. 100 req.Header.Set("anthropic-beta", OAuthAPIBetaHeader+","+FederationBetaHeader) 101 102 resp, err := handler(req) 103 if err != nil { 104 return nil, fmt.Errorf("token exchange request failed: %w", err) 105 } 106 defer resp.Body.Close() 107 108 respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) 109 if err != nil { 110 return nil, fmt.Errorf("failed to read token exchange response: %w", err) 111 } 112 113 if resp.StatusCode != http.StatusOK { 114 // A 401 is the auth-failure case worth a hint: point the operator 115 // at the federation rule and the authentication-events log in 116 // Claude Console. When no WorkspaceID is configured, also surface 117 // the most common ambiguous-401 cause — a federation rule spanning 118 // multiple workspaces — so the operator doesn't have to dig 119 // through docs. Other statuses (5xx, non-401 4xx) get no hint: 120 // they don't indicate a config problem this guidance would fix. 121 var hint string 122 if resp.StatusCode == http.StatusUnauthorized { 123 hintParts := []string{ 124 "Ensure your federation rule matches your identity token", 125 } 126 if cfg.WorkspaceID == "" { 127 hintParts = append(hintParts, 128 "If your federation rule is scoped to multiple workspaces, set the "+ 129 "ANTHROPIC_WORKSPACE_ID environment variable, the 'workspace_id' "+ 130 "config key, or the WorkspaceID field on option.FederationOptions") 131 } 132 hintParts = append(hintParts, 133 "View your authentication events in the Workload identity page of "+ 134 "Claude Console for more details") 135 hint = strings.Join(hintParts, ". ") + "." 136 } 137 return nil, &OAuthTokenError{ 138 StatusCode: resp.StatusCode, 139 Body: string(respBody), 140 RequestID: resp.Header.Get("Request-Id"), 141 Hint: hint, 142 WorkloadIdentity: true, 143 } 144 } 145 146 var result tokenExchangeResponse 147 if err := json.Unmarshal(respBody, &result); err != nil { 148 return nil, fmt.Errorf("failed to parse token exchange response: %w", err) 149 } 150 151 if result.AccessToken == "" { 152 return nil, fmt.Errorf("token exchange response missing access_token") 153 } 154 if result.TokenType != "" && !strings.EqualFold(result.TokenType, "Bearer") { 155 return nil, fmt.Errorf("token exchange response: unsupported token_type %q (want Bearer)", result.TokenType) 156 } 157 158 token := &AccessToken{Token: result.AccessToken} 159 if result.ExpiresIn != nil { 160 exp := time.Now().Add(time.Duration(*result.ExpiresIn) * time.Second) 161 token.ExpiresAt = &exp 162 } 163 return token, nil 164 } 165 }