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 }