Skip to content

Commit 24a0c3a

Browse files
feat(client): add Workload Identity Federation, interactive OAuth, and auth profiles
Add helpers for performing Workload Identity Federation based identity auth, as well as support for interactive OAuth and profile management.
1 parent bfeeb22 commit 24a0c3a

32 files changed

Lines changed: 7980 additions & 10 deletions

client.go

Lines changed: 156 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ package anthropic
44

55
import (
66
"context"
7+
"errors"
78
"fmt"
89
"net/http"
910
"os"
1011
"slices"
11-
"time"
1212
"strings"
13+
"time"
1314

15+
"github.com/anthropics/anthropic-sdk-go/config"
16+
"github.com/anthropics/anthropic-sdk-go/internal/auth"
1417
"github.com/anthropics/anthropic-sdk-go/internal/requestconfig"
1518
"github.com/anthropics/anthropic-sdk-go/option"
1619
"github.com/anthropics/anthropic-sdk-go/shared/constant"
@@ -27,9 +30,25 @@ type Client struct {
2730
Beta BetaService
2831
}
2932

30-
// DefaultClientOptions read from the environment (ANTHROPIC_API_KEY,
31-
// ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL). This should be used to initialize new
32-
// clients.
33+
// DefaultClientOptions walks the default credential chain per the
34+
// cross-SDK credential precedence spec:
35+
//
36+
// 1. ANTHROPIC_API_KEY
37+
// 2. ANTHROPIC_AUTH_TOKEN
38+
// 3. Explicit profile via ANTHROPIC_PROFILE (surfaces the error if the
39+
// named profile is missing — the user explicitly selected it)
40+
// 4. Env-var federation (ANTHROPIC_FEDERATION_RULE_ID +
41+
// ANTHROPIC_ORGANIZATION_ID + ANTHROPIC_IDENTITY_TOKEN_FILE / _TOKEN)
42+
// 5. Fallback profile (active_config file or literal "default" — a
43+
// quiet miss when absent, so a WIF-configured machine with a
44+
// leftover default profile still uses WIF)
45+
//
46+
// When no source produces a credential, the first request fails with an
47+
// [auth.NoCredentialsError]. If ANTHROPIC_PROFILE points at a missing or
48+
// invalid profile, the first request instead fails with a wrapped
49+
// profile-load error naming the profile. An explicit credential option
50+
// passed to [NewClient] (e.g. [option.WithAPIKey] or [option.WithAuthToken])
51+
// suppresses both paths. Also honors ANTHROPIC_BASE_URL.
3352
func DefaultClientOptions() []option.RequestOption {
3453
defaults := []option.RequestOption{
3554
option.WithHTTPClient(defaultHTTPClient()),
@@ -38,11 +57,58 @@ func DefaultClientOptions() []option.RequestOption {
3857
if o, ok := os.LookupEnv("ANTHROPIC_BASE_URL"); ok {
3958
defaults = append(defaults, option.WithBaseURL(o))
4059
}
41-
if o, ok := os.LookupEnv("ANTHROPIC_API_KEY"); ok {
42-
defaults = append(defaults, option.WithAPIKey(o))
60+
61+
statuses := []auth.CredentialSourceStatus{}
62+
63+
if v, ok := os.LookupEnv("ANTHROPIC_API_KEY"); ok && v != "" {
64+
defaults = append(defaults, option.WithAPIKey(v))
65+
return defaults
4366
}
44-
if o, ok := os.LookupEnv("ANTHROPIC_AUTH_TOKEN"); ok {
45-
defaults = append(defaults, option.WithAuthToken(o))
67+
statuses = append(statuses, auth.CredentialSourceStatus{
68+
Name: "ANTHROPIC_API_KEY env var",
69+
State: auth.CredentialSourceNotSet,
70+
})
71+
72+
if v, ok := os.LookupEnv("ANTHROPIC_AUTH_TOKEN"); ok && v != "" {
73+
defaults = append(defaults, option.WithAuthToken(v))
74+
return defaults
75+
}
76+
statuses = append(statuses, auth.CredentialSourceStatus{
77+
Name: "ANTHROPIC_AUTH_TOKEN env var",
78+
State: auth.CredentialSourceNotSet,
79+
})
80+
81+
// Step 3: explicit profile via ANTHROPIC_PROFILE. The user named a
82+
// specific profile, so a load failure is surfaced immediately — do
83+
// not fall through to env federation or the fallback profile.
84+
if profile, ok := os.LookupEnv("ANTHROPIC_PROFILE"); ok && profile != "" {
85+
cfg, err := config.LoadProfile(config.DefaultDir(), profile)
86+
if err != nil {
87+
return append(defaults, explicitProfileErrorOption(profile, err))
88+
}
89+
return append(defaults, option.WithConfig(cfg))
90+
}
91+
92+
// Step 4: env-var federation. Beats the fallback profile so a
93+
// WIF-configured machine with a leftover default profile file still
94+
// uses WIF.
95+
envResult, envDetail, envState := auth.EnvCredentials()
96+
if envResult != nil {
97+
defaults = append(defaults, auth.WithAuthMiddleware(envResult.Provider))
98+
return defaults
99+
}
100+
envFederationStatus := auth.CredentialSourceStatus{
101+
Name: "env federation (ANTHROPIC_FEDERATION_RULE_ID + ANTHROPIC_ORGANIZATION_ID + ANTHROPIC_IDENTITY_TOKEN_FILE)",
102+
State: envState,
103+
Detail: envDetail,
104+
}
105+
106+
// Step 5: fallback profile (active_config or literal "default"). A
107+
// missing profile here is a quiet miss — fall through to the no-
108+
// credentials aggregate.
109+
fallbackStatus, fallbackOpt := tryLoadFallbackProfile()
110+
if fallbackOpt != nil {
111+
return append(defaults, fallbackOpt)
46112
}
47113
if o, ok := os.LookupEnv("ANTHROPIC_CUSTOM_HEADERS"); ok {
48114
for _, line := range strings.Split(o, "\n") {
@@ -52,15 +118,96 @@ func DefaultClientOptions() []option.RequestOption {
52118
}
53119
}
54120
}
121+
122+
statuses = append(statuses, envFederationStatus, fallbackStatus)
123+
defaults = append(defaults, noCredentialsSentinel(statuses))
55124
return defaults
56125
}
57126

127+
// tryLoadFallbackProfile attempts the step-5 fallback profile lookup:
128+
// active_config file, otherwise literal "default". A missing profile is
129+
// reported as a silent-miss status (the caller will fall through to the
130+
// no-credentials aggregate); any other load error is reported as a
131+
// load-failure status so the user sees the specific OS error.
132+
func tryLoadFallbackProfile() (auth.CredentialSourceStatus, option.RequestOption) {
133+
cfg, err := config.LoadConfig()
134+
if err != nil {
135+
if errors.Is(err, os.ErrNotExist) {
136+
return auth.CredentialSourceStatus{
137+
Name: "profile config file",
138+
State: auth.CredentialSourceNotFound,
139+
Detail: "run `anthropic auth login` to create one",
140+
}, nil
141+
}
142+
return auth.CredentialSourceStatus{
143+
Name: "profile config file",
144+
State: auth.CredentialSourceLoadFailed,
145+
Detail: err.Error(),
146+
}, nil
147+
}
148+
return auth.CredentialSourceStatus{Name: "profile config file"}, option.WithConfig(cfg)
149+
}
150+
151+
// explicitProfileErrorOption installs a middleware that fails the request
152+
// with the underlying load error, unless a caller-supplied credential
153+
// option preempts the profile. Used when ANTHROPIC_PROFILE names a profile
154+
// whose config file cannot be loaded.
155+
func explicitProfileErrorOption(profile string, loadErr error) option.RequestOption {
156+
profileErr := fmt.Errorf("ANTHROPIC_PROFILE=%q: %w", profile, loadErr)
157+
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
158+
cfg := r
159+
check := func(req *http.Request, next func(*http.Request) (*http.Response, error)) (*http.Response, error) {
160+
if cfg.APIKey != "" || cfg.AuthToken != "" {
161+
return next(req)
162+
}
163+
if req.Header.Get("Authorization") != "" || req.Header.Get("X-Api-Key") != "" {
164+
return next(req)
165+
}
166+
return nil, profileErr
167+
}
168+
r.Middlewares = append(r.Middlewares, check)
169+
return nil
170+
})
171+
}
172+
173+
func noCredentialsSentinel(statuses []auth.CredentialSourceStatus) option.RequestOption {
174+
preBuiltErr := &auth.NoCredentialsError{Sources: statuses}
175+
return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error {
176+
cfg := r
177+
check := func(req *http.Request, next func(*http.Request) (*http.Response, error)) (*http.Response, error) {
178+
if cfg.APIKey != "" || cfg.AuthToken != "" {
179+
return next(req)
180+
}
181+
if len(cfg.Middlewares) > 1 {
182+
return next(req)
183+
}
184+
if req.Header.Get("Authorization") != "" || req.Header.Get("X-Api-Key") != "" {
185+
return next(req)
186+
}
187+
return nil, preBuiltErr
188+
}
189+
r.Middlewares = append(r.Middlewares, check)
190+
return nil
191+
})
192+
}
193+
58194
// NewClient generates a new client with the default option read from the
59195
// environment (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL). The
60196
// option passed in as arguments are applied after these default arguments, and all
61197
// option will be passed down to the services and requests that this client makes.
198+
//
199+
// Pass [option.WithoutEnvironmentDefaults] to skip the environment-based
200+
// credential autoload entirely (only the hardcoded production base-URL
201+
// default is kept). Use this when the caller does its own credential
202+
// resolution and wants the SDK to contribute nothing from the environment.
62203
func NewClient(opts ...option.RequestOption) (r Client) {
63-
opts = append(DefaultClientOptions(), opts...)
204+
var defaults []option.RequestOption
205+
if option.HasWithoutEnvironmentDefaults(opts) {
206+
defaults = []option.RequestOption{option.WithEnvironmentProduction()}
207+
} else {
208+
defaults = DefaultClientOptions()
209+
}
210+
opts = append(defaults, opts...)
64211

65212
r = Client{Options: opts}
66213

0 commit comments

Comments
 (0)