aws_assume_role 1.1.0-universal-darwin

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rubocop.yml +57 -0
  4. data/.ruby-version +1 -0
  5. data/.simplecov +22 -0
  6. data/.travis.yml +24 -0
  7. data/CHANGELOG.md +61 -0
  8. data/Gemfile +18 -0
  9. data/LICENSE.md +201 -0
  10. data/README.md +303 -0
  11. data/Rakefile +63 -0
  12. data/aws_assume_role.gemspec +56 -0
  13. data/bin/aws-assume-role +4 -0
  14. data/i18n/en.yml +109 -0
  15. data/lib/aws_assume_role.rb +4 -0
  16. data/lib/aws_assume_role/cli.rb +20 -0
  17. data/lib/aws_assume_role/cli/actions/abstract_action.rb +61 -0
  18. data/lib/aws_assume_role/cli/actions/configure_profile.rb +24 -0
  19. data/lib/aws_assume_role/cli/actions/configure_role_assumption.rb +22 -0
  20. data/lib/aws_assume_role/cli/actions/console.rb +70 -0
  21. data/lib/aws_assume_role/cli/actions/delete_profile.rb +22 -0
  22. data/lib/aws_assume_role/cli/actions/includes.rb +12 -0
  23. data/lib/aws_assume_role/cli/actions/list_profiles.rb +12 -0
  24. data/lib/aws_assume_role/cli/actions/migrate_profile.rb +20 -0
  25. data/lib/aws_assume_role/cli/actions/reset_environment.rb +50 -0
  26. data/lib/aws_assume_role/cli/actions/run.rb +36 -0
  27. data/lib/aws_assume_role/cli/actions/set_environment.rb +62 -0
  28. data/lib/aws_assume_role/cli/actions/test.rb +35 -0
  29. data/lib/aws_assume_role/cli/commands/configure.rb +32 -0
  30. data/lib/aws_assume_role/cli/commands/console.rb +19 -0
  31. data/lib/aws_assume_role/cli/commands/delete.rb +13 -0
  32. data/lib/aws_assume_role/cli/commands/environment.rb +34 -0
  33. data/lib/aws_assume_role/cli/commands/list.rb +12 -0
  34. data/lib/aws_assume_role/cli/commands/migrate.rb +13 -0
  35. data/lib/aws_assume_role/cli/commands/run.rb +19 -0
  36. data/lib/aws_assume_role/cli/commands/test.rb +20 -0
  37. data/lib/aws_assume_role/cli/includes.rb +3 -0
  38. data/lib/aws_assume_role/configuration.rb +30 -0
  39. data/lib/aws_assume_role/core_ext/aws-sdk/credential_provider_chain.rb +4 -0
  40. data/lib/aws_assume_role/core_ext/aws-sdk/includes.rb +9 -0
  41. data/lib/aws_assume_role/credentials/factories.rb +11 -0
  42. data/lib/aws_assume_role/credentials/factories/abstract_factory.rb +33 -0
  43. data/lib/aws_assume_role/credentials/factories/assume_role.rb +39 -0
  44. data/lib/aws_assume_role/credentials/factories/default_chain_provider.rb +113 -0
  45. data/lib/aws_assume_role/credentials/factories/environment.rb +26 -0
  46. data/lib/aws_assume_role/credentials/factories/includes.rb +15 -0
  47. data/lib/aws_assume_role/credentials/factories/instance_profile.rb +19 -0
  48. data/lib/aws_assume_role/credentials/factories/repository.rb +37 -0
  49. data/lib/aws_assume_role/credentials/factories/shared.rb +19 -0
  50. data/lib/aws_assume_role/credentials/factories/static.rb +18 -0
  51. data/lib/aws_assume_role/credentials/includes.rb +6 -0
  52. data/lib/aws_assume_role/credentials/providers/assume_role_credentials.rb +60 -0
  53. data/lib/aws_assume_role/credentials/providers/includes.rb +9 -0
  54. data/lib/aws_assume_role/credentials/providers/mfa_session_credentials.rb +119 -0
  55. data/lib/aws_assume_role/credentials/providers/shared_keyring_credentials.rb +41 -0
  56. data/lib/aws_assume_role/includes.rb +38 -0
  57. data/lib/aws_assume_role/logging.rb +27 -0
  58. data/lib/aws_assume_role/profile_configuration.rb +73 -0
  59. data/lib/aws_assume_role/runner.rb +40 -0
  60. data/lib/aws_assume_role/store/includes.rb +8 -0
  61. data/lib/aws_assume_role/store/keyring.rb +61 -0
  62. data/lib/aws_assume_role/store/serialization.rb +20 -0
  63. data/lib/aws_assume_role/store/shared_config_with_keyring.rb +250 -0
  64. data/lib/aws_assume_role/types.rb +31 -0
  65. data/lib/aws_assume_role/ui.rb +57 -0
  66. data/lib/aws_assume_role/vendored/aws.rb +4 -0
  67. data/lib/aws_assume_role/vendored/aws/README.md +2 -0
  68. data/lib/aws_assume_role/vendored/aws/assume_role_credentials.rb +67 -0
  69. data/lib/aws_assume_role/vendored/aws/includes.rb +9 -0
  70. data/lib/aws_assume_role/vendored/aws/refreshing_credentials.rb +58 -0
  71. data/lib/aws_assume_role/vendored/aws/shared_config.rb +223 -0
  72. data/lib/aws_assume_role/version.rb +5 -0
  73. metadata +438 -0
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "includes"
4
+ require_relative "../logging"
5
+ require_relative "keyring"
6
+ require_relative "../profile_configuration"
7
+ require_relative "../credentials/providers/mfa_session_credentials"
8
+
9
+ class AwsAssumeRole::Store::SharedConfigWithKeyring < AwsAssumeRole::Vendored::Aws::SharedConfig
10
+ include AwsAssumeRole::Store
11
+ include AwsAssumeRole::Logging
12
+
13
+ attr_reader :parsed_config
14
+
15
+ # @param [Hash] options
16
+ # @option options [String] :credentials_path Path to the shared credentials
17
+ # file. Defaults to "#{Dir.home}/.aws/credentials".
18
+ # @option options [String] :config_path Path to the shared config file.
19
+ # Defaults to "#{Dir.home}/.aws/config".
20
+ # @option options [String] :profile_name The credential/config profile name
21
+ # to use. If not specified, will check `ENV['AWS_PROFILE']` before using
22
+ # the fixed default value of 'default'.
23
+ # @option options [Boolean] :config_enabled If true, loads the shared config
24
+ # file and enables new config values outside of the old shared credential
25
+ # spec.
26
+ def initialize(options = {})
27
+ @profile_name = determine_profile(options)
28
+ @config_enabled = options[:config_enabled]
29
+ @credentials_path = options[:credentials_path] ||
30
+ determine_credentials_path
31
+ @parsed_credentials = {}
32
+ load_credentials_file if loadable?(@credentials_path)
33
+ return unless @config_enabled
34
+ @config_path = options[:config_path] || determine_config_path
35
+ load_config_file if loadable?(@config_path)
36
+ end
37
+
38
+ # @api private
39
+ def fresh(options = {})
40
+ @configuration = nil
41
+ @semaphore = nil
42
+ @assume_role_shared_config = nil
43
+ @profile_name = nil
44
+ @credentials_path = nil
45
+ @config_path = nil
46
+ @parsed_credentials = {}
47
+ @parsed_config = nil
48
+ @config_enabled = options[:config_enabled] ? true : false
49
+ @profile_name = determine_profile(options)
50
+ @credentials_path = options[:credentials_path] ||
51
+ determine_credentials_path
52
+ load_credentials_file if loadable?(@credentials_path)
53
+ return unless @config_enabled
54
+ @config_path = options[:config_path] || determine_config_path
55
+ load_config_file if loadable?(@config_path)
56
+ end
57
+
58
+ def credentials(opts = {})
59
+ logger.debug "SharedConfigWithKeyring asked for credentials with opts #{opts}"
60
+ p = opts[:profile] || @profile_name
61
+ validate_profile_exists(p) if credentials_present?
62
+ credentials_from_keyring(p, opts) || credentials_from_shared(p, opts) || credentials_from_config(p, opts)
63
+ end
64
+
65
+ def save_profile(profile_name, hash)
66
+ ckey = "profile #{profile_name}"
67
+ merged_config = configuration[ckey].deep_symbolize_keys.merge hash.to_h
68
+ merged_config[:mfa_serial] = merged_config[:serial_number] if merged_config[:serial_number]
69
+ credentials = Aws::Credentials.new(merged_config.delete(:aws_access_key_id),
70
+ merged_config.delete(:aws_secret_access_key))
71
+ semaphore.synchronize do
72
+ Keyring.save_credentials profile_name, credentials if credentials.set?
73
+ merged_config = merged_config.slice :region, :role_arn, :mfa_serial, :source_profile,
74
+ :role_session_name, :external_id, :duration_seconds,
75
+ :yubikey_oath_name
76
+ configuration.delete_section ckey
77
+ configuration[ckey] = merged_config.compact
78
+ save_configuration
79
+ end
80
+ end
81
+
82
+ def profiles
83
+ configuration.sections.map { |c| c.gsub("profile ", "") }
84
+ end
85
+
86
+ def delete_profile(profile_name)
87
+ # Keyring does not return errors for non-existent things, so always attempt.
88
+ Keyring.delete_credentials(profile_name)
89
+ semaphore.synchronize do
90
+ raise KeyError if configuration["profile #{profile_name}"].blank?
91
+ configuration.delete_section("profile #{profile_name}")
92
+ save_configuration
93
+ end
94
+ end
95
+
96
+ def migrate_profile(profile_name)
97
+ validate_profile_exists(profile_name)
98
+ save_profile(profile_name, configuration["profile #{profile_name}"])
99
+ end
100
+
101
+ def profile_region(profile_name)
102
+ resolve_profile_parameter(profile_name, "region")
103
+ end
104
+
105
+ def profile_role(profile_name)
106
+ resolve_profile_parameter(profile_name, "role_arn")
107
+ end
108
+
109
+ def profile_hash(profile_name)
110
+ {} || @parsed_config[profile_key(profile_name)]
111
+ end
112
+
113
+ private
114
+
115
+ def profile_key(profile)
116
+ logger.debug "About to lookup #{profile}"
117
+ if profile == "default" || profile.nil? || profile == ""
118
+ "default"
119
+ else
120
+ profile
121
+ end
122
+ end
123
+
124
+ def resolve_profile_parameter(profile_name, param)
125
+ return unless @parsed_config
126
+ prof_cfg = @parsed_config[profile_key(profile_name)]
127
+ resolve_parameter(param, @parsed_config, prof_cfg)
128
+ end
129
+
130
+ def resolve_parameter(param, cfg, prof_cfg)
131
+ return unless prof_cfg && cfg
132
+ return prof_cfg[param] if prof_cfg.key? param
133
+ source_profile = prof_cfg["source_profile"]
134
+ return unless source_profile
135
+ source_cfg = cfg[source_profile]
136
+ return unless source_cfg
137
+ cfg[prof_cfg["source_profile"]][param] if source_cfg.key?(param)
138
+ end
139
+
140
+ def resolve_region(cfg, prof_cfg)
141
+ resolve_parameter("region", cfg, prof_cfg)
142
+ end
143
+
144
+ def resolve_arn(cfg, prof_cfg)
145
+ resolve_parameter("role_arn", cfg, prof_cfg)
146
+ end
147
+
148
+ def assume_role_from_profile(cfg, profile, opts)
149
+ logger.debug "Entering assume_role_from_profile with #{cfg}, #{profile}, #{opts}"
150
+ prof_cfg = cfg[profile]
151
+ return unless cfg && prof_cfg
152
+ opts[:source_profile] ||= prof_cfg["source_profile"]
153
+ if opts[:source_profile]
154
+ opts[:credentials] = credentials(profile: opts[:source_profile])
155
+ if opts[:credentials]
156
+ opts[:role_session_name] ||= prof_cfg["role_session_name"]
157
+ opts[:role_session_name] ||= "default_session"
158
+ opts[:role_arn] ||= prof_cfg["role_arn"]
159
+ opts[:external_id] ||= prof_cfg["external_id"]
160
+ opts[:serial_number] ||= prof_cfg["mfa_serial"]
161
+ opts[:yubikey_oath_name] ||= prof_cfg["yubikey_oath_name"]
162
+ opts[:region] ||= profile_region(profile)
163
+ if opts[:serial_number]
164
+ mfa_opts = {
165
+ credentials: opts[:credentials],
166
+ region: opts[:region],
167
+ serial_number: opts[:serial_number],
168
+ yubikey_oath_name: opts[:yubikey_oath_name],
169
+ }
170
+ mfa_creds = mfa_session(cfg, opts[:source_profile], mfa_opts)
171
+ opts.delete :serial_number
172
+ end
173
+ opts[:credentials] = mfa_creds if mfa_creds
174
+ opts[:profile] = opts.delete(:source_profile)
175
+ AwsAssumeRole::Credentials::Providers::AssumeRoleCredentials.new(opts)
176
+ else
177
+ raise ::Aws::Errors::NoSourceProfileError, "Profile #{profile} has a role_arn, and source_profile, but the"\
178
+ " source_profile does not have credentials."
179
+ end
180
+ elsif prof_cfg["role_arn"]
181
+ raise ::Aws::Errors::NoSourceProfileError, "Profile #{profile} has a role_arn, but no source_profile."
182
+ end
183
+ end
184
+
185
+ def mfa_session(cfg, profile, opts)
186
+ prof_cfg = cfg[profile]
187
+ return unless cfg && prof_cfg
188
+ opts[:serial_number] ||= opts[:mfa_serial] || prof_cfg["mfa_serial"]
189
+ opts[:source_profile] ||= prof_cfg["source_profile"]
190
+ opts[:region] ||= profile_region(profile)
191
+ return unless opts[:serial_number]
192
+ opts[:credentials] ||= credentials(profile: opts[:profile])
193
+ AwsAssumeRole::Credentials::Providers::MfaSessionCredentials.new(opts)
194
+ end
195
+
196
+ def credentials_from_keyring(profile, opts)
197
+ logger.debug "Entering credentials_from_keyring"
198
+ return unless @parsed_config
199
+ logger.debug "credentials_from_keyring: @parsed_config found"
200
+ prof_cfg = @parsed_config[profile]
201
+ return unless prof_cfg
202
+ logger.debug "credentials_from_keyring: prof_cfg found"
203
+ opts[:serial_number] ||= opts[:mfa_serial] || prof_cfg[:mfa_serial] || prof_cfg[:serial_number]
204
+ if opts[:serial_number]
205
+ logger.debug "credentials_from_keyring detected mfa requirement"
206
+ mfa_session(@parsed_config, profile, opts)
207
+ else
208
+ logger.debug "Attempt to fetch #{profile} from keyring"
209
+ keyring_creds = Keyring.fetch(profile)
210
+ return unless keyring_creds
211
+ creds = Serialization.credentials_from_hash Keyring.fetch(profile)
212
+ creds if credentials_complete(creds)
213
+ end
214
+ rescue Aws::Errors::NoSourceProfileError, Aws::Errors::NoSuchProfileError
215
+ nil
216
+ end
217
+
218
+ def semaphore
219
+ @semaphore ||= Mutex.new
220
+ end
221
+
222
+ def configuration
223
+ @configuration ||= IniFile.new(filename: determine_config_path, default: "default")
224
+ end
225
+
226
+ # Please run in a mutex
227
+ def save_configuration
228
+ if File.exist? determine_config_path
229
+ bytes_required = File.size(determine_config_path)
230
+ # Overwrite the current .config file with random bytes to eliminate
231
+ # unencrypted credentials.
232
+ # This won't account for COW filesystems or SSD wear-levelling but
233
+ # is a best effort protection.
234
+ random_bytes = SecureRandom.random_bytes(bytes_required)
235
+ File.write(determine_config_path, random_bytes)
236
+ else
237
+ FileUtils.mkdir_p(Pathname.new(determine_config_path).dirname)
238
+ end
239
+ configuration.save
240
+ end
241
+ end
242
+
243
+ module AwsAssumeRole
244
+ module_function
245
+
246
+ def shared_config
247
+ enabled = ENV["AWS_SDK_CONFIG_OPT_OUT"] ? false : true
248
+ @assume_role_shared_config ||= ::AwsAssumeRole::Store::SharedConfigWithKeyring.new(config_enabled: enabled)
249
+ end
250
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "includes"
4
+ module AwsAssumeRole
5
+ module Types
6
+ Dry = Dry::Types.module
7
+
8
+ ::Dry::Types.register_class(::Aws::Credentials)
9
+ AwsAssumeRole::Types::Credentials = ::Dry::Types["aws.credentials"]
10
+
11
+ ACCESS_KEY_REGEX = /[\w]+/
12
+ ACCESS_KEY_VALIDATOR = proc { filled? & str? & format?(ACCESS_KEY_REGEX) & min_size?(16) & max_size?(32) }
13
+ ARN_REGEX = %r{arn:[\w+=\/,.@-]+:[\w+=\/,.@-]+:[\w+=\/,.@-]*:[0-9]+:[\w+=,.@-]+(\/[\w+=\/,.@-]+)*}
14
+ EXTERNAL_ID_REGEX = %r{[\w+=,.@:\/-]*}
15
+ MFA_REGEX = %r{arn:aws:iam::[0-9]+:mfa\/([\w+=,.@-]+)*|automatic}
16
+ REGION_REGEX = /^(us|eu|ap|sa|ca)\-\w+\-\d+$|^cn\-\w+\-\d+$|^us\-gov\-\w+\-\d+$/
17
+ REGION_VALIDATOR = proc { filled? & str? & format?(REGION_REGEX) }
18
+ ROLE_REGEX = %r{arn:aws:iam::[0-9]+:role\/([\w+=,.@-]+)*}
19
+ ROLE_SESSION_NAME_REGEX = /[\w+=,.@-]*/
20
+ SECRET_ACCESS_KEY_REGEX = //
21
+ SECRET_ACCESS_KEY_VALIDATOR = proc { filled? & str? & format?(SECRET_ACCESS_KEY_REGEX) }
22
+
23
+ AwsAssumeRole::Types::Region = Dry::Strict::String.constrained(
24
+ format: REGION_REGEX,
25
+ )
26
+
27
+ AwsAssumeRole::Types::MfaSerial = Dry::Strict::String.constrained(
28
+ format: MFA_REGEX,
29
+ )
30
+ end
31
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "includes"
4
+
5
+ module AwsAssumeRole::Ui
6
+ include AwsAssumeRole
7
+
8
+ ::I18n.load_path += Dir.glob(File.join(File.realpath(__dir__), "..", "..", "i18n", "*.{rb,yml,yaml}"))
9
+ ::I18n.locale = ENV.fetch("LANG", nil).split(".").first.split("_").first || I18n.default_locale
10
+
11
+ module_function
12
+
13
+ def out(text)
14
+ puts text
15
+ end
16
+
17
+ def pastel
18
+ @pastel ||= Pastel.new
19
+ end
20
+
21
+ def input
22
+ @input ||= HighLine.new($stdin, $stderr)
23
+ end
24
+
25
+ def validation_errors_to_s(result)
26
+ text = result.errors.keys.map do |k|
27
+ result.errors[k].join(";")
28
+ end.join(" ")
29
+ text
30
+ end
31
+
32
+ def error(text)
33
+ puts pastel.red(text)
34
+ end
35
+
36
+ def show_validation_errors(result)
37
+ error validation_errors_to_s(result)
38
+ end
39
+
40
+ def ask_with_validation(variable_name, question, type: Dry::Types["coercible.string"], &block)
41
+ STDOUT.puts pastel.yellow question
42
+ validator = Dry::Validation.Schema do
43
+ configure do
44
+ config.messages = :i18n
45
+ end
46
+ required(variable_name) { instance_eval(&block) }
47
+ end
48
+ result = validator.call(variable_name => type[(STDIN.gets || "").chomp])
49
+ return result.to_h[variable_name] if result.success?
50
+ show_validation_errors result
51
+ ask_with_validation variable_name, question, &block
52
+ end
53
+
54
+ def t(*options)
55
+ ::I18n.t(options).first
56
+ end
57
+ end
@@ -0,0 +1,4 @@
1
+ require_relative "aws/assume_role_credentials"
2
+ require_relative "aws/refreshing_credentials"
3
+ require_relative "../store/shared_config_with_keyring"
4
+ require_relative "aws/shared_config"
@@ -0,0 +1,2 @@
1
+ These are copies of the private API from the AWS SDK v2, as partial protection to changes, for which the source is available at
2
+ https://github.com/aws/aws-sdk-ruby
@@ -0,0 +1,67 @@
1
+ require_relative "includes"
2
+
3
+ module AwsAssumeRole::Vendored::Aws
4
+ # An auto-refreshing credential provider that works by assuming
5
+ # a role via {Aws::STS::Client#assume_role}.
6
+ #
7
+ # role_credentials = Aws::AssumeRoleCredentials.new(
8
+ # client: Aws::STS::Client.new(...),
9
+ # role_arn: "linked::account::arn",
10
+ # role_session_name: "session-name"
11
+ # )
12
+ #
13
+ # ec2 = Aws::EC2::Client.new(credentials: role_credentials)
14
+ #
15
+ # If you omit `:client` option, a new {STS::Client} object will be
16
+ # constructed.
17
+ class AssumeRoleCredentials
18
+ include ::Aws::CredentialProvider
19
+ include ::Aws::RefreshingCredentials
20
+
21
+ # @option options [required, String] :role_arn
22
+ # @option options [required, String] :role_session_name
23
+ # @option options [String] :policy
24
+ # @option options [Integer] :duration_seconds
25
+ # @option options [String] :external_id
26
+ # @option options [STS::Client] :client
27
+ def initialize(options = {}, **)
28
+
29
+ client_opts = {}
30
+ @assume_role_params = {}
31
+ options.each_pair do |key, value|
32
+ if self.class.assume_role_options.include?(key)
33
+ @assume_role_params[key] = value
34
+ else
35
+ client_opts[key] = value
36
+ end
37
+ end
38
+ @client = client_opts[:client] || ::Aws::STS::Client.new(client_opts)
39
+ super
40
+ end
41
+
42
+ # @return [STS::Client]
43
+ attr_reader :client
44
+
45
+ private
46
+
47
+ def refresh
48
+ c = @client.assume_role(@assume_role_params).credentials
49
+ @credentials = ::Aws::Credentials.new(
50
+ c.access_key_id,
51
+ c.secret_access_key,
52
+ c.session_token,
53
+ )
54
+ @expiration = c.expiration
55
+ end
56
+
57
+ class << self
58
+ # @api private
59
+ def assume_role_options
60
+ @aro ||= begin
61
+ input = ::Aws::STS::Client.api.operation(:assume_role).input
62
+ Set.new(input.shape.member_names)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,9 @@
1
+ require_relative "../../includes"
2
+
3
+ module AwsAssumeRole
4
+ module Vendored
5
+ module Aws
6
+ include ::Aws
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,58 @@
1
+ module AwsAssumeRole::Vendored::Aws
2
+ # Base class used credential classes that can be refreshed. This
3
+ # provides basic refresh logic in a thread-safe manor. Classes mixing in
4
+ # this module are expected to implement a #refresh method that populates
5
+ # the following instance variables:
6
+ #
7
+ # * `@access_key_id`
8
+ # * `@secret_access_key`
9
+ # * `@session_token`
10
+ # * `@expiration`
11
+ #
12
+ # @api private
13
+ module RefreshingCredentials
14
+ def initialize(_options = {})
15
+ @mutex = Mutex.new
16
+ refresh
17
+ end
18
+
19
+ # @return [Credentials]
20
+ def credentials
21
+ refresh_if_near_expiration
22
+ @credentials
23
+ end
24
+
25
+ # @return [Time,nil]
26
+ def expiration
27
+ refresh_if_near_expiration
28
+ @expiration
29
+ end
30
+
31
+ # Refresh credentials.
32
+ # @return [void]
33
+ def refresh!
34
+ @mutex.synchronize { refresh }
35
+ end
36
+
37
+ private
38
+
39
+ # Refreshes instance metadata credentials if they are within
40
+ # 5 minutes of expiration.
41
+ def refresh_if_near_expiration
42
+ if near_expiration?
43
+ @mutex.synchronize do
44
+ refresh if near_expiration?
45
+ end
46
+ end
47
+ end
48
+
49
+ def near_expiration?
50
+ if @expiration
51
+ # are we within 5 minutes of expiration?
52
+ (Time.now.to_i + 5 * 60) > @expiration.to_i
53
+ else
54
+ true
55
+ end
56
+ end
57
+ end
58
+ end