github.com/anthropics/anthropic-sdk-go@v1.70.1/internal/auth/credentials_file.go (about) 1 package auth 2 3 import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "fmt" 8 "io" 9 "io/fs" 10 "net/http" 11 "os" 12 "runtime" 13 "strings" 14 "time" 15 16 "github.com/anthropics/anthropic-sdk-go/config" 17 "github.com/anthropics/anthropic-sdk-go/internal" 18 ) 19 20 // Credentials file schema — credentials/<name>.json. Reads only; all writes 21 // go through config.WriteCredentials for a single-source-of-truth on the 22 // atomic + fsync + Chmod guarantees. 23 type credentialsTokenData struct { 24 Type string `json:"type"` 25 AccessToken string `json:"access_token"` 26 ExpiresAt *int64 `json:"expires_at,omitempty"` 27 RefreshToken string `json:"refresh_token,omitempty"` 28 } 29 30 func readCredentialsFile(path string) (*credentialsTokenData, error) { 31 if err := checkCredentialsFileSafety(path); err != nil { 32 return nil, err 33 } 34 data, err := os.ReadFile(path) 35 if err != nil { 36 return nil, err 37 } 38 var cred credentialsTokenData 39 if err := json.Unmarshal(data, &cred); err != nil { 40 return nil, err 41 } 42 return &cred, nil 43 } 44 45 // checkCredentialsFileSafety refuses credentials files exposed to other 46 // UIDs on the host. Symlinks could redirect a refresh-token write; any 47 // mode&0o077 bit lets another UID read or inject a token. Mode bits are 48 // not meaningful on Windows, so that check is skipped there. 49 func checkCredentialsFileSafety(path string) error { 50 info, err := os.Lstat(path) 51 if err != nil { 52 return err 53 } 54 if info.Mode().Type()&fs.ModeSymlink != 0 { 55 return fmt.Errorf("credentials file %q is a symlink; refusing to read (set ANTHROPIC_CREDENTIALS_PATH to the real file)", path) 56 } 57 if runtime.GOOS == "windows" { 58 return nil 59 } 60 mode := info.Mode().Perm() 61 if mode&0o077 != 0 { 62 return fmt.Errorf("credentials file %q has unsafe permissions %#o; refusing to read (must not be accessible by group or other; chmod 600 %s)", path, mode, path) 63 } 64 return nil 65 } 66 67 // freshAccessToken returns a non-nil [AccessToken] only when the credentials 68 // contain a non-empty token that won't expire within [MandatoryRefreshThreshold]. 69 // If ExpiresAt is nil the token is treated as non-expiring. 70 func (cred *credentialsTokenData) freshAccessToken() *AccessToken { 71 if cred.AccessToken == "" { 72 return nil 73 } 74 if cred.ExpiresAt != nil { 75 exp := time.Unix(*cred.ExpiresAt, 0) 76 if time.Until(exp) <= MandatoryRefreshThreshold { 77 return nil 78 } 79 return &AccessToken{Token: cred.AccessToken, ExpiresAt: &exp} 80 } 81 return &AccessToken{Token: cred.AccessToken} 82 } 83 84 // ResolveCredentials builds a [CredentialsResult] from a [config.Config]. 85 // The [AuthenticationInfo.CredentialsPath] must already be resolved to an 86 // absolute path (or left empty if credentials are not file-based). 87 func ResolveCredentials(cfg *config.Config) (*CredentialsResult, error) { 88 if cfg.AuthenticationInfo == nil { 89 return nil, &CredentialResolutionError{ 90 Message: "config is missing authentication", 91 } 92 } 93 switch cfg.AuthenticationInfo.Type { 94 case config.AuthenticationTypeOIDCFederation: 95 return loadOIDCFederationProfile(cfg) 96 case config.AuthenticationTypeUserOAuth: 97 return loadUserOAuthProfile(cfg) 98 default: 99 return nil, &CredentialResolutionError{ 100 Message: fmt.Sprintf("unknown authentication.type %q", cfg.AuthenticationInfo.Type), 101 } 102 } 103 } 104 105 func loadOIDCFederationProfile(cfg *config.Config) (*CredentialsResult, error) { 106 oidc := cfg.AuthenticationInfo.OIDCFederation 107 if oidc == nil { 108 return nil, &CredentialResolutionError{ 109 Message: "oidc_federation config missing oidc_federation sub-object", 110 } 111 } 112 if oidc.FederationRuleID == "" || cfg.OrganizationID == "" { 113 return nil, &CredentialResolutionError{ 114 Message: "oidc_federation config missing federation_rule_id or organization_id", 115 } 116 } 117 118 var identityProvider IdentityTokenProvider 119 switch { 120 case oidc.IdentityToken != nil: 121 if oidc.IdentityToken.Source != config.IdentityTokenSourceFile { 122 return nil, &CredentialResolutionError{ 123 Message: fmt.Sprintf("oidc_federation identity_token.source %q is not supported (only %q)", oidc.IdentityToken.Source, config.IdentityTokenSourceFile), 124 } 125 } 126 if oidc.IdentityToken.Path == "" { 127 return nil, &CredentialResolutionError{ 128 Message: fmt.Sprintf("oidc_federation identity_token.source %q requires a non-empty path", config.IdentityTokenSourceFile), 129 } 130 } 131 identityProvider = &IdentityTokenFile{Path: oidc.IdentityToken.Path} 132 default: 133 identityProvider = identityProviderFromEnv() 134 if identityProvider == nil { 135 return nil, &CredentialResolutionError{ 136 Message: fmt.Sprintf("oidc_federation config requires identity_token with source %q, or %s / %s environment variable", config.IdentityTokenSourceFile, EnvIdentityTokenFile, EnvIdentityToken), 137 } 138 } 139 } 140 141 exchangeProvider := NewOIDCFederationCredentials(OIDCFederationConfig{ 142 IdentityProvider: identityProvider, 143 FederationRuleID: oidc.FederationRuleID, 144 OrganizationID: cfg.OrganizationID, 145 ServiceAccountID: oidc.ServiceAccountID, 146 WorkspaceID: cfg.WorkspaceID, 147 BaseURL: cfg.BaseURL, 148 }) 149 150 credPath := cfg.AuthenticationInfo.CredentialsPath 151 152 // Wrap with cache-aware provider that checks the credentials file first. 153 provider := func(ctx context.Context, baseURL string, handler func(*http.Request) (*http.Response, error)) (*AccessToken, error) { 154 // Try cached credentials file, unless the caller signaled a force- 155 // refresh (e.g. after a 401 invalidation in the auth middleware). 156 if !isForceRefresh(ctx) { 157 if cred, err := readCredentialsFile(credPath); err == nil { 158 if token := cred.freshAccessToken(); token != nil { 159 return token, nil 160 } 161 } else if !os.IsNotExist(err) { 162 warnOnce("workload-cache-read:"+credPath, 163 "failed to read workload-identity token cache %q: %v (continuing with fresh exchange)", credPath, err) 164 } 165 } 166 167 // Exchange for a new token. 168 token, err := exchangeProvider(ctx, baseURL, handler) 169 if err != nil { 170 return nil, err 171 } 172 173 // Write cache back (best-effort; log once on failure so a misconfigured 174 // cache directory does not cause a silent exchange-per-request loop). 175 if token.ExpiresAt != nil { 176 cacheData := config.Credentials{ 177 AccessToken: token.Token, 178 ExpiresAt: token.ExpiresAt, 179 } 180 if writeErr := config.WriteCredentials(credPath, cacheData); writeErr != nil { 181 warnOnce("workload-cache-write:"+credPath, 182 "failed to write workload-identity token cache %q: %v", credPath, writeErr) 183 } 184 } 185 186 return token, nil 187 } 188 189 // For federation profiles workspace_id is sent in the jwt-bearer 190 // exchange body, not as a request header (the minted token is already 191 // workspace-scoped, so the header would be ignored). WorkspaceID is 192 // therefore intentionally omitted from the CredentialsResult here. 193 return &CredentialsResult{ 194 Provider: provider, 195 BaseURL: cfg.BaseURL, 196 }, nil 197 } 198 199 func loadUserOAuthProfile(cfg *config.Config) (*CredentialsResult, error) { 200 userOAuth := cfg.AuthenticationInfo.UserOAuth 201 if userOAuth == nil { 202 return nil, &CredentialResolutionError{ 203 Message: "user_oauth config missing user_oauth sub-object", 204 } 205 } 206 207 credPath := cfg.AuthenticationInfo.CredentialsPath 208 cred, err := readCredentialsFile(credPath) 209 if err != nil { 210 return nil, &CredentialResolutionError{ 211 Message: fmt.Sprintf("failed to read credentials file %q", credPath), 212 Err: err, 213 } 214 } 215 216 if cred.AccessToken == "" { 217 return nil, &CredentialResolutionError{ 218 Message: fmt.Sprintf("user_oauth credentials in %q missing access_token", credPath), 219 } 220 } 221 222 if userOAuth.ClientID == "" { 223 // Without a client_id we cannot refresh. Fail fast on an already- 224 // expired token so callers get a clean error instead of a 401-retry 225 // loop until maxRetries on a silently stale bearer. 226 if cred.freshAccessToken() == nil { 227 return nil, &CredentialResolutionError{ 228 Message: fmt.Sprintf("user_oauth credentials in %q: access_token is expired or missing and no refresh is available (client_id is empty)", credPath), 229 } 230 } 231 // Re-read on every call so externally-rotated tokens (e.g. from a 232 // credential daemon) are picked up. 233 staticProvider := func(_ context.Context, _ string, _ func(*http.Request) (*http.Response, error)) (*AccessToken, error) { 234 current, err := readCredentialsFile(credPath) 235 if err != nil { 236 return nil, fmt.Errorf("failed to read credentials file %q: %w", credPath, err) 237 } 238 token := current.freshAccessToken() 239 if token == nil { 240 return nil, fmt.Errorf("user_oauth credentials in %q: access_token is expired or missing and no refresh is available (client_id is empty)", credPath) 241 } 242 return token, nil 243 } 244 return &CredentialsResult{ 245 Provider: staticProvider, 246 BaseURL: cfg.BaseURL, 247 WorkspaceID: cfg.WorkspaceID, 248 }, nil 249 } 250 251 if cred.RefreshToken == "" { 252 return nil, &CredentialResolutionError{ 253 Message: fmt.Sprintf("user_oauth credentials in %q has client_id in config but missing refresh_token", credPath), 254 } 255 } 256 257 provider := func(ctx context.Context, baseURL string, handler func(*http.Request) (*http.Response, error)) (*AccessToken, error) { 258 base := strings.TrimRight(cfg.BaseURL, "/") 259 if base == "" { 260 base = strings.TrimRight(baseURL, "/") 261 } 262 if err := requireSecureTokenEndpoint(base); err != nil { 263 return nil, err 264 } 265 // Re-read credentials file to check if token is still fresh. Skip 266 // the freshness shortcut when force-refresh is signaled (e.g. after 267 // a 401 in the auth middleware). 268 current, err := readCredentialsFile(credPath) 269 if err != nil { 270 return nil, fmt.Errorf("failed to read credentials file %q: %w", credPath, err) 271 } 272 if !isForceRefresh(ctx) { 273 if token := current.freshAccessToken(); token != nil { 274 return token, nil 275 } 276 } 277 278 // Refresh the token. `scope` is intentionally omitted: the Python 279 // oauth-server preserves the original token's full scope set when 280 // scope is absent on the request (see the early-return in 281 // api/api/oauth_server/oauth/grants/refresh_token_grant.py). 282 refreshReq := map[string]string{ 283 "grant_type": GrantTypeRefreshToken, 284 "refresh_token": current.RefreshToken, 285 "client_id": userOAuth.ClientID, 286 } 287 refreshBody, _ := json.Marshal(refreshReq) 288 289 endpoint := base + TokenEndpoint 290 req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(refreshBody)) 291 if err != nil { 292 return nil, fmt.Errorf("failed to create refresh request: %w", err) 293 } 294 req.Header.Set("Content-Type", "application/json") 295 req.Header.Set("User-Agent", "anthropic-sdk-go/"+internal.PackageVersion+" (user-oauth-refresh)") 296 // The oauth-2025-04-20 beta is required by the Python oauth-server 297 // to accept refresh-token grants for user_oauth credentials. 298 // 299 // Do NOT add FederationBetaHeader here. POST /v1/oauth/token is 300 // gateway-routed: with oidc-federation-2026-04-01 present it goes 301 // to the Go userauth service, which only handles the jwt-bearer 302 // grant. Refresh-token grants must fall through to the Python 303 // oauth-server, where the federation beta must be absent. 304 req.Header.Set("anthropic-beta", OAuthAPIBetaHeader) 305 306 resp, err := handler(req) 307 if err != nil { 308 return nil, fmt.Errorf("refresh request failed: %w", err) 309 } 310 defer resp.Body.Close() 311 312 respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) 313 if err != nil { 314 return nil, fmt.Errorf("failed to read refresh response: %w", err) 315 } 316 317 if resp.StatusCode != http.StatusOK { 318 return nil, &OAuthTokenError{ 319 StatusCode: resp.StatusCode, 320 Body: string(respBody), 321 RequestID: resp.Header.Get("Request-Id"), 322 } 323 } 324 325 var result struct { 326 AccessToken string `json:"access_token"` 327 TokenType string `json:"token_type,omitempty"` 328 RefreshToken string `json:"refresh_token"` 329 ExpiresIn *int `json:"expires_in,omitempty"` 330 } 331 if err := json.Unmarshal(respBody, &result); err != nil { 332 return nil, fmt.Errorf("failed to parse refresh response: %w", err) 333 } 334 if result.TokenType != "" && !strings.EqualFold(result.TokenType, "Bearer") { 335 return nil, fmt.Errorf("refresh response: unsupported token_type %q (want Bearer)", result.TokenType) 336 } 337 338 updated := config.Credentials{ 339 AccessToken: result.AccessToken, 340 RefreshToken: current.RefreshToken, 341 } 342 if result.RefreshToken != "" { 343 updated.RefreshToken = result.RefreshToken 344 } 345 token := &AccessToken{Token: result.AccessToken} 346 if result.ExpiresIn != nil { 347 exp := time.Now().Add(time.Duration(*result.ExpiresIn) * time.Second) 348 updated.ExpiresAt = &exp 349 token.ExpiresAt = &exp 350 } 351 352 if err := config.WriteCredentials(credPath, updated); err != nil { 353 return nil, fmt.Errorf("failed to write updated credentials: %w", err) 354 } 355 return token, nil 356 } 357 358 return &CredentialsResult{ 359 Provider: provider, 360 BaseURL: cfg.BaseURL, 361 WorkspaceID: cfg.WorkspaceID, 362 }, nil 363 }