Class: Anthropic::Credentials::ConfigProvider

Inherits:
Object
  • Object
show all
Defined in:
lib/anthropic/credentials/config_provider.rb,
sig/anthropic/credentials.rbs

Overview

Abstract base for access-token providers backed by a config object, whether read from disk (CredentialsFile) or supplied in memory (InMemoryConfig).

Subclasses must implement:

  • load_config (private) -- parse and cache the config hash into @config, populate @base_url (validated via require_https!), and resolve @credentials_path if applicable. Idempotent.
  • config_source (private) -- a string used in error messages to identify the configuration's origin (a file path or a sentinel).

Direct Known Subclasses

CredentialsFile, InMemoryConfig

Instance Method Summary collapse

Constructor Details

#initializeConfigProvider

Returns a new instance of ConfigProvider.



16
17
18
19
20
21
22
# File 'lib/anthropic/credentials/config_provider.rb', line 16

def initialize
  @config = nil
  @credentials_path = nil
  @base_url = nil
  @bound_base_url = nil
  @workload_delegate = nil
end

Instance Method Details

#auth_blockHash

Returns the authentication block from the config.

Returns:

  • (Hash)

    the authentication block from the config



110
111
112
113
# File 'lib/anthropic/credentials/config_provider.rb', line 110

def auth_block
  config = load_config
  config[:authentication]
end

#bind_base_url(base_url) ⇒ void

This method returns an undefined value.

Sets the owning client's base_url as a fallback for token exchange.

Parameters:

  • base_url (String)

    the base URL (must be HTTPS except for localhost)



59
60
61
62
63
64
65
66
67
# File 'lib/anthropic/credentials/config_provider.rb', line 59

def bind_base_url(base_url)
  bound = base_url.to_s.chomp("/")
  Anthropic::Config.require_https!(bound, field: "base_url")
  @bound_base_url = bound

  return unless @config
  @base_url = resolve_base_url(@config)
  Anthropic::Config.require_https!(@base_url, field: "#{config_source}: base_url")
end

#build_workload_delegate(auth) ⇒ WorkloadIdentity

Builds a WorkloadIdentity delegate for OIDC federation.

Parameters:

  • auth (Hash)

    the authentication block

Returns:



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/anthropic/credentials/config_provider.rb', line 182

def build_workload_delegate(auth)
  federation_rule_id = auth[:federation_rule_id]
  organization_id = @config[:organization_id]

  unless federation_rule_id && organization_id
    raise Anthropic::Errors::ConfigurationError,
          "Config file with authentication.type #{AUTH_TYPE_OIDC_FEDERATION.inspect} must include " \
          "'authentication.federation_rule_id' and top-level 'organization_id': #{config_source}"
  end

  provider = resolve_identity_token_provider(auth)

  delegate = WorkloadIdentity.new(
    identity_token_provider: provider,
    federation_rule_id: federation_rule_id,
    organization_id: organization_id,
    service_account_id: auth[:service_account_id],
    workspace_id: @config[:workspace_id],
    scope: auth[:scope]
  )

  delegate.bind_base_url(@base_url)
  delegate
end

#call(force_refresh: false) ⇒ AccessToken

Returns an access token, performing token exchange if necessary.

Parameters:

  • force_refresh (Boolean) (defaults to: false)

    if true, bypasses any cached token

  • force_refresh: (Boolean) (defaults to: false)

Returns:

Raises:



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/anthropic/credentials/config_provider.rb', line 30

def call(force_refresh: false)
  auth = auth_block
  auth_type = auth[:type]

  case auth_type
  when AUTH_TYPE_OIDC_FEDERATION
    call_oidc_federation(auth, force_refresh: force_refresh)
  when AUTH_TYPE_USER_OAUTH
    call_user_oauth(auth, force_refresh: force_refresh)
  else
    raise Anthropic::Errors::ConfigurationError,
          "Unknown authentication.type #{auth_type.inspect} at #{config_source}. " \
          "Expected #{AUTH_TYPE_OIDC_FEDERATION.inspect} or #{AUTH_TYPE_USER_OAUTH.inspect}."
  end
end

#call_oidc_federation(auth, force_refresh:) ⇒ AccessToken

Handles OIDC federation authentication.

Parameters:

  • auth (Hash)

    the authentication block

  • force_refresh (Boolean)

    whether to force a fresh exchange

Returns:



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/anthropic/credentials/config_provider.rb', line 120

def call_oidc_federation(auth, force_refresh:)
  @workload_delegate ||= build_workload_delegate(auth)

  return @workload_delegate.call(force_refresh: force_refresh) unless @credentials_path

  cached = read_credentials_if_exists
  if !force_refresh && cached
    access_token = cached[:access_token]
    expires_at = cached[:expires_at]

    if access_token && expires_at
      begin
        expires_at_int = Integer(expires_at)
        if Time.now.to_i < expires_at_int - MANDATORY_REFRESH_SECONDS
          return AccessToken.new(token: access_token.to_s, expires_at: expires_at_int)
        end
      rescue ArgumentError, TypeError # rubocop:disable Lint/SuppressedException
      end
    end
  end

  token = @workload_delegate.call(force_refresh: force_refresh)

  begin
    cached_strings = cached&.transform_keys(&:to_s) || {}
    write_credentials(
      cached_strings.merge(
        "version" => "1.0",
        "type" => "oauth_token",
        "access_token" => token.token,
        "expires_at" => token.expires_at
      )
    )
  rescue StandardError
    # cache write failures are non-fatal; return the freshly minted token regardless
  end

  token
end

#call_user_oauth(_auth, force_refresh:) ⇒ AccessToken

Handles user OAuth authentication.

Parameters:

  • auth (Hash)

    the authentication block

  • force_refresh (Boolean)

    ignored; refresh not implemented

Returns:

  • (AccessToken)

    the access token from the credentials file



165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/anthropic/credentials/config_provider.rb', line 165

def call_user_oauth(_auth, force_refresh:) # rubocop:disable Lint/UnusedMethodArgument
  creds = read_credentials
  access_token = creds[:access_token]

  unless access_token
    raise Anthropic::Errors::ConfigurationError,
          "Credentials file at #{@credentials_path} is missing 'access_token'."
  end

  expires_at = coerce_expires_at(creds[:expires_at])
  AccessToken.new(token: access_token, expires_at: expires_at)
end

#coerce_expires_at(value) ⇒ Integer?

Coerces an expires_at value to an Integer.

Parameters:

  • value (Object, nil)

    the value to coerce

Returns:

  • (Integer, nil)

    the coerced value

Raises:



371
372
373
374
375
376
377
378
379
# File 'lib/anthropic/credentials/config_provider.rb', line 371

def coerce_expires_at(value)
  return nil if value.nil?

  Integer(value)
rescue ArgumentError, TypeError
  raise Anthropic::Errors::ConfigurationError,
        "Credentials file at #{@credentials_path} has invalid 'expires_at' #{value.inspect}; " \
        "expected an integer Unix timestamp in seconds."
end

#config_source#to_s

This method is abstract.

Subclass must return a human-readable identifier of the config origin used in error messages (a file path or a sentinel string).

Returns:

  • (#to_s)

Raises:

  • (NotImplementedError)


105
106
107
# File 'lib/anthropic/credentials/config_provider.rb', line 105

def config_source
  raise NotImplementedError, "#{self.class} must implement #config_source"
end

#extra_headersHash{String => String}

Returns headers derived from the config (e.g., workspace_id).

Returns:

  • (Hash{String => String})

    headers to merge into API requests



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/anthropic/credentials/config_provider.rb', line 72

def extra_headers
  config = load_config
  headers = {}

  auth_type = config[:authentication][:type]
  # For federation profiles workspace_id is sent in the jwt-bearer
  # exchange body, not as a request header (the minted token is already
  # workspace-scoped, so the header would be ignored).
  if auth_type != AUTH_TYPE_OIDC_FEDERATION
    workspace_id = config[:workspace_id]
    headers["anthropic-workspace-id"] = workspace_id.to_s if workspace_id
  end

  headers
end

#fill(target, key, env_var) ⇒ void

This method returns an undefined value.

Fills a single field from an environment variable if not already set.

Parameters:

  • target (Hash)

    the hash to fill

  • key (Symbol)

    the key to fill

  • env_var (String)

    the environment variable name



424
425
426
427
428
429
# File 'lib/anthropic/credentials/config_provider.rb', line 424

def fill(target, key, env_var)
  value = target[key]
  return unless value.nil? || (value.is_a?(String) && value.empty?)
  v = ENV[env_var]
  target[key] = v if v && !v.empty?
end

#fill_missing_from_env(config, auth) ⇒ void

This method returns an undefined value.

Fills missing config fields from environment variables.

Parameters:

  • config (Hash)

    the config hash

  • auth (Hash)

    the authentication block



398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/anthropic/credentials/config_provider.rb', line 398

def fill_missing_from_env(config, auth)
  fill(config, :base_url, ENV_BASE_URL)
  fill(config, :organization_id, ENV_ORGANIZATION_ID)
  fill(config, :workspace_id, ENV_WORKSPACE_ID)

  auth_type = auth[:type]
  if auth_type == AUTH_TYPE_OIDC_FEDERATION
    fill(auth, :federation_rule_id, ENV_FEDERATION_RULE_ID)
    fill(auth, :service_account_id, ENV_SERVICE_ACCOUNT_ID)
    fill(auth, :scope, ENV_SCOPE)

    unless auth[:identity_token]
      v = ENV[ENV_IDENTITY_TOKEN_FILE]
      auth[:identity_token] = {source: "file", path: v} if v && !v.empty?
    end
  elsif auth_type == AUTH_TYPE_USER_OAUTH
    fill(auth, :scope, ENV_SCOPE)
  end
end

#load_configHash

This method is abstract.

Subclass must populate and cache @config (symbolized hash), set @base_url (and validate it via Anthropic::Config.require_https! when applicable), and set @credentials_path if the config references one. Must be idempotent: a no-op when @config is already set.

Returns the loaded config hash with symbol keys.

Returns:

  • (Hash)

    the loaded config hash with symbol keys

Raises:

  • (NotImplementedError)


96
97
98
# File 'lib/anthropic/credentials/config_provider.rb', line 96

def load_config
  raise NotImplementedError, "#{self.class} must implement #load_config"
end

#read_credentialsHash

Reads the credentials file. Re-reads on every call -- daemons rotate it.

On Unix, verifies the file is not group/world-readable. World-readable credentials files are refused outright; group-readable files log a warning but are accepted. The check is skipped on Windows where POSIX mode bits don't carry the same meaning.

Returns:

  • (Hash)

    the parsed credentials

Raises:



267
268
269
270
271
272
273
274
275
276
277
278
279
280
# File 'lib/anthropic/credentials/config_provider.rb', line 267

def read_credentials
  validate_credentials_permissions!

  raw = @credentials_path.read
  JSON.parse(raw, symbolize_names: true)
rescue Anthropic::Errors::ConfigurationError
  raise
rescue JSON::ParserError => e
  raise Anthropic::Errors::ConfigurationError,
        "Credentials file at #{@credentials_path} is not valid JSON: #{e.message}"
rescue StandardError => e
  raise Anthropic::Errors::ConfigurationError,
        "Credentials file at #{@credentials_path} could not be read: #{e.message}"
end

#read_credentials_if_existsHash?

Reads the credentials file if it exists.

Returns:

  • (Hash, nil)

    the parsed credentials, or nil if not found



330
331
332
333
334
335
336
# File 'lib/anthropic/credentials/config_provider.rb', line 330

def read_credentials_if_exists
  return nil unless @credentials_path&.exist?

  read_credentials
rescue Anthropic::Errors::ConfigurationError
  nil
end

#resolve_base_url(config) ⇒ String

Returns the resolved base URL.

Parameters:

  • config (Hash)

    the config hash

Returns:

  • (String)

    the resolved base URL



383
384
385
386
387
388
389
390
391
# File 'lib/anthropic/credentials/config_provider.rb', line 383

def resolve_base_url(config)
  if config[:base_url]
    config[:base_url].to_s.chomp("/")
  elsif @bound_base_url
    @bound_base_url
  else
    DEFAULT_BASE_URL
  end
end

#resolve_identity_token_provider(auth) ⇒ #call

Resolves the identity token provider from the auth config.

Parameters:

  • auth (Hash)

    the authentication block

Returns:

  • (#call)

    the provider (IdentityTokenFile or a Proc)



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/anthropic/credentials/config_provider.rb', line 211

def resolve_identity_token_provider(auth)
  identity_token_cfg = auth[:identity_token]

  return IdentityTokenFile.new unless identity_token_cfg

  if identity_token_cfg.is_a?(Hash)
    source = identity_token_cfg[:source]

    case source
    when "file"
      identity_token_path = identity_token_cfg[:path]
      unless identity_token_path && !identity_token_path.empty?
        raise Anthropic::Errors::ConfigurationError,
              "identity_token source 'file' requires a non-empty path; " \
              "#{config_source} has identity_token=#{identity_token_cfg.inspect}."
      end
      IdentityTokenFile.new(identity_token_path)
    when "env"
      env_var_name = identity_token_cfg[:value]
      unless env_var_name && !env_var_name.empty?
        raise Anthropic::Errors::ConfigurationError,
              "identity_token source 'env' requires a non-empty value; " \
              "#{config_source} has identity_token=#{identity_token_cfg.inspect}."
      end
      -> {
        content = ENV[env_var_name]
        if content.nil?
          raise Anthropic::Errors::ConfigurationError,
                "Identity token environment variable #{env_var_name} is not set"
        end
        content = content.strip
        if content.empty?
          raise Anthropic::Errors::ConfigurationError,
                "Identity token environment variable #{env_var_name} is empty"
        end
        content
      }
    else
      raise Anthropic::Errors::ConfigurationError,
            "identity_token source #{source.inspect} is not supported; only 'file' and 'env' are implemented"
    end
  else
    identity_token_path = identity_token_cfg.to_s
    IdentityTokenFile.new(identity_token_path)
  end
end

#resolved_base_urlString?

Returns the base_url from the config, if set.

Returns:

  • (String, nil)

    the base URL, or nil if not configured



49
50
51
52
53
# File 'lib/anthropic/credentials/config_provider.rb', line 49

def resolved_base_url
  config = load_config
  raw = config[:base_url]
  raw ? raw.to_s.chomp("/") : nil
end

#write_credentials(data) ⇒ void

This method returns an undefined value.

Atomically writes to the credentials file.

Parameters:

  • data (Hash)

    the data to write



342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
# File 'lib/anthropic/credentials/config_provider.rb', line 342

def write_credentials(data)
  temp_path = nil
  parent = @credentials_path.parent
  parent.mkpath
  parent.chmod(0o700)

  temp_path = @credentials_path.dirname.join(".#{@credentials_path.basename}.tmp#{SecureRandom.hex(8)}")

  File.open(temp_path, "w", 0o600) do |f|
    f.write(JSON.pretty_generate(data))
    f.fsync
  end

  File.rename(temp_path, @credentials_path)
ensure
  unless temp_path.nil?
    begin
      File.unlink(temp_path)
    rescue StandardError
      # best-effort cleanup; the temp file may already be gone (successful rename or concurrent unlink)
    end
  end
end