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  }