prx-ruby-aws-creds 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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: []