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  }