prx-ruby-aws-creds 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/prx-ruby-aws-creds.rb +146 -69
  3. metadata +73 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7abb2239df6342948e68ed047013e67a326292b3dcde4bc474fa6464ca4ed56c
4
- data.tar.gz: 2e709d49a0cbe389bf996c05c296702d36deb731c32e7f72f3fea7e548b12e5b
3
+ metadata.gz: de3e7ce4a5d260687d001b85f356d8ed44f0042cccb441f0713302b9a463588a
4
+ data.tar.gz: 809cf2c28520f1881dddaa6c39f9a7a38fc9cce84e9ea2ec04fd0fe176c9fdf9
5
5
  SHA512:
6
- metadata.gz: fc8e689995304d5bd5fab0b3ed37a1ca1ae2753a0b79f9a57b94cf17f66b508bb47830c9cd34906d0c3abeee0dcdda0cb42c5a775f6f1a5977b5e19282695ad7
7
- data.tar.gz: a30158aea20392ea4fceb69b440bc439b00ed5bdd7e8ac21a77621c753654ddee412e39e456cba7b3f4d811890dbd5fc4b4fd82e22cc0d32805cf6e1600ff9cd
6
+ metadata.gz: 36c906b505621065a7239e5f6ce04b62a3d11d474cdcb2c20b87b0ed67b3fbc7b1cb3ff4ab6456e1c5db95515a1eef839045d215cf746e6448762c8bed2d7f00
7
+ data.tar.gz: a3819f034b34ee12f2695afbbdcef6e3fcf70574b46e8e67730a8c97cdf44c1e52289eb47f0a2e1ba24fc8fdafaef5ad6f9d4c4d4a889c3f491af0d7eeaa1e3e
@@ -1,66 +1,105 @@
1
+ require "json"
2
+ require "digest"
3
+ require "time"
4
+ require "fileutils"
5
+ require "io/console"
6
+ require "inifile"
7
+ require "aws-sdk-core"
8
+ require "aws-sdk-sts"
9
+ require "aws-sdk-sso"
10
+
11
+ CACHE_DIRECTORY = "#{Dir.home}/.aws/ruby/cache"
1
12
  AWS_CONFIG_FILE = ENV["AWS_CONFIG_FILE"] || "#{Dir.home}/.aws/config"
2
13
 
3
14
  class PrxRubyAwsCreds
4
15
  class << self
5
- def cache_directory
6
- "#{Dir.home}/.aws/ruby/cache"
7
- end
8
-
9
- def cache_key_path(assume_role_options)
10
- # The cache key is based on the parameters used for the AssumeRole call.
11
- # The role session name is removed if it's randomly generated (which it
12
- # always is for us). If the options were ever to include a policy document,
13
- # that should get sorted before hashing.
14
- # https://github.com/boto/botocore/blob/88d780dea1684da00689f2eef388fa4c782ced08/botocore/credentials.py#L700
15
- key_opts = assume_role_options.clone
16
+ # The cache key is based on the parameters used to request temporary
17
+ # credentials (using either STS AssumeRole or SSO GetRoleCredentials). The
18
+ # role session name is removed if it's randomly generated (which it always
19
+ # is for us). If the options were ever to include a policy document, that
20
+ # should get sorted before hashing.
21
+ # https://github.com/boto/botocore/blob/88d780dea1684da00689f2eef388fa4c782ced08/botocore/credentials.py#L700
22
+ #
23
+ # For any
24
+ def cache_key_path(role_options)
25
+ key_opts = role_options.clone
16
26
  key_opts.delete(:role_session_name)
27
+ key_opts.delete(:access_token)
17
28
  cache_key = Digest::SHA1.hexdigest(JSON.dump(key_opts))
18
29
 
19
- "#{cache_directory}/#{cache_key}.json"
30
+ "#{CACHE_DIRECTORY}/#{cache_key}.json"
20
31
  end
21
32
 
22
- # Returns the options passed to SSO#get_role_credentials. This is used when the
23
- # profile uses an SSO, rather than a key/secret. If the selected profile is
24
- # not configured for SSO, returns nil.
25
- def sso_get_role_options
26
- aws_config_file = IniFile.load(AWS_CONFIG_FILE)
27
- aws_config_file_section = aws_config_file["profile #{OPTS[:profile]}"]
28
-
29
- if aws_config_file_section["sso_start_url"]
30
- profile_start_url = aws_config_file_section["sso_start_url"]
31
-
32
- sso_access_token = nil
33
- Dir["#{Dir.home}/.aws/sso/cache/*.json"].each do |path|
34
- data = JSON.parse(File.read(path))
35
- if data["startUrl"] && data["startUrl"] == profile_start_url
36
- sso_access_token = data["accessToken"]
37
- break
33
+ # For a given SSO start URL, return a valid access token from the cache. If
34
+ # no valid token is found, returns nil. An access token will only be
35
+ # considered valid if it has not expired.
36
+ def sso_get_cached_access_token(start_url)
37
+ Dir["#{Dir.home}/.aws/sso/cache/*.json"].each do |path|
38
+ data = JSON.parse(File.read(path))
39
+ if data["startUrl"] && data["startUrl"] == start_url
40
+ expiration = Time.parse(data["expiresAt"])
41
+
42
+ if expiration > Time.now
43
+ return data["accessToken"]
38
44
  end
39
45
  end
46
+ end
40
47
 
41
- if !sso_access_token
42
- puts "No SSO access token was found for this profile."
43
- puts "Press RETURN to request a token in a web browser."
44
- puts "You can do this manually with: 'aws sso login --profile #{OPTS[:profile]}'"
45
- inp = $stdin.gets.chomp
46
- `aws sso login --profile #{OPTS[:profile]}` if inp.empty?
47
- end
48
+ nil
49
+ end
48
50
 
49
- {
50
- role_name: aws_config_file_section["sso_role_name"],
51
- account_id: aws_config_file_section["sso_account_id"].to_s,
52
- access_token: sso_access_token
53
- }
51
+ # Returns the options passed to SSO#get_role_credentials. This is used when
52
+ # the profile uses an SSO, rather than a key/secret. If the selected
53
+ # profile is not configured for SSO, returns nil.
54
+ #
55
+ # `role_name` and `account_id` are values found in the config file for the
56
+ # given profile. `access_token` is a SSO token found in the SSO cache for
57
+ # the SSO start URL associated with the given profile.
58
+ def sso_get_role_options(profile_name)
59
+ aws_config_file = IniFile.load(AWS_CONFIG_FILE)
60
+ aws_config_file_section = aws_config_file["profile #{profile_name}"]
61
+
62
+ # The selected profile does not use SSO
63
+ return if !aws_config_file_section["sso_start_url"]
64
+
65
+ # Get the SSO start URL for the selected profile
66
+ profile_start_url = aws_config_file_section["sso_start_url"]
67
+
68
+ sso_access_token = sso_get_cached_access_token(profile_start_url)
69
+
70
+ # If a valid token wasn't found in the cache, prompt the user to fetch a
71
+ # new one.
72
+ if !sso_access_token
73
+ puts
74
+ puts "No #{"access token".yellow} was found for this SSO start URL associated with this profile (#{profile_start_url.blue})."
75
+ puts "Press #{"RETURN".green} to request a new token. This will open a web browser."
76
+ puts "You can also do this manually with: 'aws sso login --profile #{profile_name}'".gray
77
+ puts
78
+ inp = $stdin.gets.chomp
79
+ `aws sso login --profile #{profile_name}` if inp.empty?
80
+ sso_access_token = sso_get_cached_access_token(profile_start_url)
81
+ puts "This #{"access token".yellow} is valid for all SSO profiles using #{profile_start_url.blue} as their start URL."
82
+ puts
54
83
  end
84
+
85
+ {
86
+ role_name: aws_config_file_section["sso_role_name"],
87
+ account_id: aws_config_file_section["sso_account_id"].to_s,
88
+ access_token: sso_access_token
89
+ }
55
90
  end
56
91
 
57
- # Returns the options passed to AssumeRole. This is used when the profile uses
58
- # a key/secret. If the selected profile is not configured for key/secret,
59
- # returns nil.
60
- def assume_role_options
92
+ # Returns the options passed to AssumeRole. This is used when the profile
93
+ # uses a key/secret. If the selected profile is not configured for
94
+ # key/secret, returns nil.
95
+ def assume_role_options(profile_name)
61
96
  aws_config_file = IniFile.load(AWS_CONFIG_FILE)
62
- aws_config_file_section = aws_config_file["profile #{OPTS[:profile]}"]
97
+ aws_config_file_section = aws_config_file["profile #{profile_name}"]
98
+
99
+ # Get the role ARN for the selected profile
63
100
  role_arn = aws_config_file_section["role_arn"]
101
+
102
+ # Extract some values from the ARN
64
103
  role_name = role_arn.split("role/")[1]
65
104
  account_id = role_arn.split(":")[4]
66
105
 
@@ -71,49 +110,85 @@ class PrxRubyAwsCreds
71
110
  }
72
111
  end
73
112
 
74
- def get_and_cache_credentials
75
- FileUtils.mkdir_p cache_directory
113
+ # Makes a request to some AWS API endpoint that can generate temporary IAM
114
+ # credentials (e.g., AssumeRole, GetRoleCredentials, etc) based on the
115
+ # configuration of the selected profile.
116
+ #
117
+ # Cache the resulting credentials in the CACHE_DIRECTORY. The file is named
118
+ # using a hash of the options passed to the endpoint.
119
+ def get_and_cache_credentials(profile_name)
120
+ # Make sure the cache directory exists
121
+ FileUtils.mkdir_p CACHE_DIRECTORY
76
122
 
77
123
  aws_config_file = IniFile.load(AWS_CONFIG_FILE)
78
- aws_config_file_section = aws_config_file["profile #{OPTS[:profile]}"]
124
+ aws_config_file_section = aws_config_file["profile #{profile_name}"]
79
125
 
80
126
  if aws_config_file_section["sso_role_name"]
81
- opts = sso_get_role_options
82
-
83
- sso = Aws::SSO::Client.new(region: aws_config_file_section["region"])
127
+ # For SSO profiles, call GetRoleCredentials with a role, account, and
128
+ # access token to get back a set of temporary credentials.
129
+ # https://docs.aws.amazon.com/singlesignon/latest/PortalAPIReference/API_GetRoleCredentials.html
130
+ # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/SSO/Client.html#get_role_credentials-instance_method
131
+ opts = sso_get_role_options(profile_name)
132
+ sso = Aws::SSO::Client.new(region: aws_config_file_section["sso_region"])
84
133
  credentials = sso.get_role_credentials(opts)
85
134
 
135
+ # Cache the credentials. The structure of this file doesn't exactly
136
+ # match what native libraries (boto, etc) use. Instead, it matches the
137
+ # default output of assume_role. It could be anything, it just needs
138
+ # to be consistent across profile types, and match what
139
+ # load_and_verify_cached_credentials expects.
86
140
  File.write(cache_key_path(opts), JSON.dump({"credentials" => {
87
141
  "access_key_id" => credentials.role_credentials.access_key_id,
88
142
  "secret_access_key" => credentials.role_credentials.secret_access_key,
89
143
  "session_token" => credentials.role_credentials.session_token
90
144
  }}))
91
145
 
146
+ # Return the temporary IAM credentials
92
147
  Aws::Credentials.new(credentials.role_credentials.access_key_id, credentials.role_credentials.secret_access_key, credentials.role_credentials.session_token)
93
148
  elsif aws_config_file_section["mfa_serial"]
149
+ # For profiles using an API key with an MFA token, get the serial
150
+ # number of the MFA device associated with the profile.
94
151
  mfa_serial = aws_config_file_section["mfa_serial"]
95
152
 
153
+ # Prompt the user for the current TOTP code associated with the MFA
154
+ # device.
96
155
  mfa_code = $stdin.getpass("Enter MFA code for #{mfa_serial}: ")
97
- credentials = Aws.shared_config.assume_role_credentials_from_config(profile: OPTS[:profile], token_code: mfa_code.chomp)
98
- sts = Aws::STS::Client.new(
99
- region: "us-east-1",
100
- credentials: credentials
101
- )
156
+
157
+ # Get a set of credentials for the role configured in the profile using
158
+ # the TOTP code. I don't remember why, but I don't think these
159
+ # credentials should be used for anything other than making another
160
+ # call to assume_role. Don't cache or return these credentials.
161
+ # Note: This is marked as a private API
162
+ credentials = Aws.shared_config.assume_role_credentials_from_config(profile: profile_name, token_code: mfa_code.chomp)
163
+ sts = Aws::STS::Client.new(region: "us-east-1", credentials: credentials)
164
+
165
+ # Make a call to get_caller_identity to ensure that the first set of
166
+ # credentials are valid?
102
167
  _id = sts.get_caller_identity
103
168
 
169
+ # Make a regular assume_role call to get standard temporary IAM
170
+ # credentials.
104
171
  opts = assume_role_options
105
- cacheable_role = sts.assume_role(assume_role_options)
172
+ cacheable_role = sts.assume_role(opts)
106
173
  File.write(cache_key_path(opts), JSON.dump(cacheable_role.to_h))
107
174
 
175
+ # Return the temporary IAM credentials
108
176
  Aws::Credentials.new(cacheable_role["credentials"]["access_key_id"], cacheable_role["credentials"]["secret_access_key"], cacheable_role["credentials"]["session_token"])
109
177
  end
110
178
  rescue Aws::SSO::Errors::UnauthorizedException
111
- raise "The SSO access token for this profile is invalid. Run 'aws sso login --profile #{OPTS[:profile]}' to fetch a valid token."
179
+ raise "The SSO access token for this profile is invalid. Run 'aws sso login --profile #{profile_name}' to fetch a valid token."
112
180
  end
113
181
 
114
- def load_and_verify_cached_credentials
182
+ # For the selected profile, look for a set of cached temporary IAM
183
+ # credentials. These are vanilla IAM credentials that look the same
184
+ # regardless of what type of profile is selected (SSO, MFA, etc).
185
+ #
186
+ # If no cached credential exist for the profile, or if the credentials are
187
+ # invalid (i.e., can't successfully call get_caller_identity), a new set
188
+ # of credentials will be fetched and cached.
189
+ def load_and_verify_cached_credentials(profile_name)
115
190
  # Look up the cache file based on the options for the seleted profile.
116
- options = sso_get_role_options || assume_role_options
191
+ options = sso_get_role_options(profile_name) || assume_role_options(profile_name)
117
192
 
118
193
  cached_role_json = File.read(cache_key_path(options))
119
194
  cached_role = JSON.parse(cached_role_json)
@@ -127,18 +202,20 @@ class PrxRubyAwsCreds
127
202
 
128
203
  credentials
129
204
  rescue Aws::STS::Errors::ExpiredToken
130
- get_and_cache_credentials
205
+ get_and_cache_credentials(profile_name)
131
206
  rescue Aws::STS::Errors::InvalidClientTokenId
132
- get_and_cache_credentials
207
+ get_and_cache_credentials(profile_name)
133
208
  rescue Errno::ENOENT
134
- get_and_cache_credentials
209
+ get_and_cache_credentials(profile_name)
135
210
  end
136
211
 
137
- # Returns temporary client credentials for the profile selected with --profile
138
- # when the command was run.
139
- def client_credentials
212
+ # Returns temporary IAM client (Aws::Credentials) credentials for a given
213
+ # profile.
214
+ def client_credentials(profile_name = nil)
215
+ profile_name ||= OPTS[:profile]
216
+
140
217
  # For the selected profile, get the appropriate set of options.
141
- options = sso_get_role_options || assume_role_options
218
+ options = sso_get_role_options(profile_name) || assume_role_options(profile_name)
142
219
 
143
220
  return if !options
144
221
 
@@ -146,11 +223,11 @@ class PrxRubyAwsCreds
146
223
  if !File.file?(cache_key_path(options))
147
224
  # When no cache exists for these options, fetch new credentials, cache them
148
225
  # and return them.
149
- get_and_cache_credentials
226
+ get_and_cache_credentials(profile_name)
150
227
  else
151
228
  # When there is a cache for these options, return them if they are still
152
229
  # valid, otherwise refresh them and return the new credentials.
153
- load_and_verify_cached_credentials
230
+ load_and_verify_cached_credentials(profile_name)
154
231
  end
155
232
  end
156
233
  end
metadata CHANGED
@@ -1,15 +1,85 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prx-ruby-aws-creds
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christopher Kalafarski
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-04-04 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2023-04-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: inifile
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: nokogiri
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: aws-sdk-core
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: aws-sdk-sso
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: aws-sdk-sts
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
13
83
  description: tktk
14
84
  email: chris.kalafarski@prx.org
15
85
  executables: []