github.com/anthropics/anthropic-sdk-go@v1.70.1/config/federation.go (about)

     1  package config
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"net/http"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/anthropics/anthropic-sdk-go/internal"
    15  )
    16  
    17  // OAuth 2.0 wire-contract constants. These are the authoritative
    18  // definitions; internal/auth re-exports them as local aliases so that
    19  // in-tree auth code can reference them without importing config and
    20  // forming a cycle.
    21  const (
    22  	// TokenEndpoint is the path of the Anthropic OAuth 2.0 token
    23  	// endpoint — the destination for jwt-bearer exchanges,
    24  	// refresh_token grants, and (future) authorization_code grants.
    25  	TokenEndpoint = "/v1/oauth/token"
    26  
    27  	// GrantTypeJWTBearer is the RFC 7523 grant type string used for
    28  	// OIDC federation exchanges.
    29  	GrantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer"
    30  
    31  	// GrantTypeRefreshToken is the RFC 6749 §6 grant type string used
    32  	// for rotating user_oauth access tokens.
    33  	GrantTypeRefreshToken = "refresh_token"
    34  
    35  	// OAuthAPIBetaHeader is the anthropic-beta value required on
    36  	// authenticated API requests using an OAuth bearer token, and on
    37  	// refresh_token grants against the token endpoint.
    38  	OAuthAPIBetaHeader = "oauth-2025-04-20"
    39  
    40  	// FederationBetaHeader is the anthropic-beta value required on
    41  	// jwt-bearer exchanges against the token endpoint. It routes the
    42  	// request to the Go userauth service; it must NOT be sent on
    43  	// refresh_token grants, which are gateway-routed to the Python
    44  	// oauth-server.
    45  	FederationBetaHeader = "oidc-federation-2026-04-01"
    46  )
    47  
    48  const (
    49  	defaultAPIBaseURL = "https://api.anthropic.com"
    50  	// federationExchangeBetaValue is the combined anthropic-beta value
    51  	// for jwt-bearer exchanges: oauth-2025-04-20 unlocks the oauth/token
    52  	// endpoint family, and oidc-federation-2026-04-01 routes jwt-bearer
    53  	// to the federation service.
    54  	federationExchangeBetaValue = OAuthAPIBetaHeader + "," + FederationBetaHeader
    55  )
    56  
    57  // FederationExchangeParams captures the inputs needed to exchange a signed
    58  // third-party assertion (GitHub OIDC, Kubernetes service account token,
    59  // etc.) for a short-lived Anthropic access token via the jwt-bearer grant.
    60  type FederationExchangeParams struct {
    61  	// Assertion is the signed JWT presented to the token endpoint. Required.
    62  	Assertion string
    63  
    64  	// FederationRuleID is the tagged ID ("fdrl_...") of the OidcFederationRule
    65  	// that governs the exchange. Required.
    66  	FederationRuleID string
    67  
    68  	// OrganizationID is the tagged ID of the Anthropic organization whose
    69  	// credentials the exchange should mint. Required.
    70  	OrganizationID string
    71  
    72  	// ServiceAccountID is an optional "svac_..." target check for
    73  	// federation rules with target_type=SERVICE_ACCOUNT. Leave empty for
    74  	// user-targeted rules.
    75  	ServiceAccountID string
    76  
    77  	// WorkspaceID is an optional `wrkspc_*` tagged ID, or the literal
    78  	// "default" to scope the token to the organization's default workspace.
    79  	// When omitted the server picks the rule's sole enabled workspace, else
    80  	// the org default if the rule covers it. Required when the rule enables
    81  	// more than one non-default workspace, or to target a specific workspace
    82  	// other than the one the server would pick. The minted token is
    83  	// workspace-scoped: per-request workspace selection (the
    84  	// anthropic-workspace-id header) is not supported for federation
    85  	// tokens — switching workspaces requires a new token exchange with a
    86  	// different WorkspaceID.
    87  	WorkspaceID string
    88  
    89  	// BaseURL overrides the Anthropic API base URL. Defaults to
    90  	// https://api.anthropic.com. A trailing slash is tolerated.
    91  	BaseURL string
    92  
    93  	// HTTPClient overrides the default HTTP client used for the exchange.
    94  	// When nil, a client with a 30s timeout is used.
    95  	HTTPClient *http.Client
    96  
    97  	// UserAgent overrides the outgoing User-Agent header. When empty, the
    98  	// helper sends "anthropic-sdk-go/<version> ExchangeFederationAssertion"
    99  	// so the token endpoint's access logs identify the caller for
   100  	// incident triage. Callers with their own tooling (e.g. `ant-cli/1.2.3`)
   101  	// should set this so support tickets can point at the real binary.
   102  	UserAgent string
   103  }
   104  
   105  // federationExchangeRequest mirrors the JSON body the server's token
   106  // endpoint accepts. The REST gateway's AliasTransformation strips any
   107  // field not in this exact set, so each JSON tag is load-bearing — sending
   108  // "federation_rule" or "organization" (without "_id") causes the gateway
   109  // to drop the value and the Connect handler to fail with an empty
   110  // federation_rule_id error. Keep in sync with
   111  // internal/auth/workload.go:tokenExchangeRequest.
   112  type federationExchangeRequest struct {
   113  	GrantType        string `json:"grant_type"`
   114  	Assertion        string `json:"assertion"`
   115  	FederationRuleID string `json:"federation_rule_id"`
   116  	OrganizationID   string `json:"organization_id"`
   117  	ServiceAccountID string `json:"service_account_id,omitempty"`
   118  	WorkspaceID      string `json:"workspace_id,omitempty"`
   119  }
   120  
   121  // FederationExchangeError is returned by [ExchangeFederationAssertion] when
   122  // the token endpoint responds with a non-2xx status. The server body is
   123  // kept verbatim so the caller can surface the exact upstream message; the
   124  // Request-Id header is captured separately so support tickets can include
   125  // a correlation identifier.
   126  type FederationExchangeError struct {
   127  	StatusCode int
   128  	Body       string
   129  	RequestID  string
   130  }
   131  
   132  func (e *FederationExchangeError) Error() string {
   133  	redacted := RedactOAuthErrorBody(e.Body)
   134  	if e.RequestID != "" {
   135  		return fmt.Sprintf("federation exchange failed (status %d, request-id %s): %s",
   136  			e.StatusCode, e.RequestID, redacted)
   137  	}
   138  	return fmt.Sprintf("federation exchange failed (status %d): %s", e.StatusCode, redacted)
   139  }
   140  
   141  // ExchangeFederationAssertion performs an OAuth 2.0 jwt-bearer exchange
   142  // against the Anthropic token endpoint and returns the minted credentials.
   143  //
   144  // Federation grants do not return a refresh token — callers re-exchange
   145  // their assertion on expiry. The returned [*Credentials] therefore has an
   146  // empty RefreshToken but a populated ExpiresAt whenever the server returns
   147  // an expires_in field.
   148  //
   149  // On non-2xx responses the returned error is a [*FederationExchangeError]
   150  // carrying the server body and the Request-Id response header.
   151  func ExchangeFederationAssertion(ctx context.Context, params FederationExchangeParams) (*Credentials, error) {
   152  	if params.Assertion == "" {
   153  		return nil, errors.New("ExchangeFederationAssertion: Assertion is required")
   154  	}
   155  	if params.FederationRuleID == "" {
   156  		return nil, errors.New("ExchangeFederationAssertion: FederationRuleID is required")
   157  	}
   158  	if params.OrganizationID == "" {
   159  		return nil, errors.New("ExchangeFederationAssertion: OrganizationID is required")
   160  	}
   161  
   162  	base := strings.TrimRight(params.BaseURL, "/")
   163  	if base == "" {
   164  		base = defaultAPIBaseURL
   165  	}
   166  
   167  	bodyJSON, err := json.Marshal(federationExchangeRequest{
   168  		GrantType:        GrantTypeJWTBearer,
   169  		Assertion:        params.Assertion,
   170  		FederationRuleID: params.FederationRuleID,
   171  		OrganizationID:   params.OrganizationID,
   172  		ServiceAccountID: params.ServiceAccountID,
   173  		WorkspaceID:      params.WorkspaceID,
   174  	})
   175  	if err != nil {
   176  		return nil, fmt.Errorf("ExchangeFederationAssertion: marshal body: %w", err)
   177  	}
   178  
   179  	req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+TokenEndpoint, bytes.NewReader(bodyJSON))
   180  	if err != nil {
   181  		return nil, fmt.Errorf("ExchangeFederationAssertion: build request: %w", err)
   182  	}
   183  	req.Header.Set("Content-Type", "application/json")
   184  	req.Header.Set("anthropic-beta", federationExchangeBetaValue)
   185  	ua := params.UserAgent
   186  	if ua == "" {
   187  		ua = "anthropic-sdk-go/" + internal.PackageVersion + " ExchangeFederationAssertion"
   188  	}
   189  	req.Header.Set("User-Agent", ua)
   190  
   191  	client := params.HTTPClient
   192  	if client == nil {
   193  		client = &http.Client{Timeout: 30 * time.Second}
   194  	}
   195  	resp, err := client.Do(req)
   196  	if err != nil {
   197  		return nil, fmt.Errorf("ExchangeFederationAssertion: post: %w", err)
   198  	}
   199  	defer resp.Body.Close()
   200  
   201  	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
   202  	if err != nil {
   203  		return nil, fmt.Errorf("ExchangeFederationAssertion: read response: %w", err)
   204  	}
   205  
   206  	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
   207  		return nil, &FederationExchangeError{
   208  			StatusCode: resp.StatusCode,
   209  			Body:       string(body),
   210  			RequestID:  resp.Header.Get("Request-Id"),
   211  		}
   212  	}
   213  
   214  	var parsed struct {
   215  		AccessToken string `json:"access_token"`
   216  		TokenType   string `json:"token_type,omitempty"`
   217  		ExpiresIn   *int   `json:"expires_in,omitempty"`
   218  	}
   219  	if err := json.Unmarshal(body, &parsed); err != nil {
   220  		return nil, fmt.Errorf("ExchangeFederationAssertion: parse response: %w", err)
   221  	}
   222  	if parsed.AccessToken == "" {
   223  		return nil, errors.New("ExchangeFederationAssertion: response missing access_token")
   224  	}
   225  	if parsed.TokenType != "" && !strings.EqualFold(parsed.TokenType, "Bearer") {
   226  		return nil, fmt.Errorf("ExchangeFederationAssertion: unsupported token_type %q (want Bearer)", parsed.TokenType)
   227  	}
   228  
   229  	creds := &Credentials{AccessToken: parsed.AccessToken}
   230  	if parsed.ExpiresIn != nil {
   231  		exp := time.Now().Add(time.Duration(*parsed.ExpiresIn) * time.Second)
   232  		creds.ExpiresAt = &exp
   233  	}
   234  	return creds, nil
   235  }