prx-ruby-aws-creds 0.0.35 → 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/prx-ruby-aws-creds.rb +146 -69
- metadata +73 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: de3e7ce4a5d260687d001b85f356d8ed44f0042cccb441f0713302b9a463588a
|
4
|
+
data.tar.gz: 809cf2c28520f1881dddaa6c39f9a7a38fc9cce84e9ea2ec04fd0fe176c9fdf9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 36c906b505621065a7239e5f6ce04b62a3d11d474cdcb2c20b87b0ed67b3fbc7b1cb3ff4ab6456e1c5db95515a1eef839045d215cf746e6448762c8bed2d7f00
|
7
|
+
data.tar.gz: a3819f034b34ee12f2695afbbdcef6e3fcf70574b46e8e67730a8c97cdf44c1e52289eb47f0a2e1ba24fc8fdafaef5ad6f9d4c4d4a889c3f491af0d7eeaa1e3e
|
data/lib/prx-ruby-aws-creds.rb
CHANGED
@@ -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
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
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
|
-
"#{
|
30
|
+
"#{CACHE_DIRECTORY}/#{cache_key}.json"
|
20
31
|
end
|
21
32
|
|
22
|
-
#
|
23
|
-
#
|
24
|
-
#
|
25
|
-
def
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
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
|
-
|
42
|
-
|
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
|
-
|
51
|
-
|
52
|
-
|
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
|
58
|
-
# a key/secret. If the selected profile is not configured for
|
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 #{
|
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
|
-
|
75
|
-
|
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 #{
|
124
|
+
aws_config_file_section = aws_config_file["profile #{profile_name}"]
|
79
125
|
|
80
126
|
if aws_config_file_section["sso_role_name"]
|
81
|
-
|
82
|
-
|
83
|
-
|
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
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
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(
|
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 #{
|
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
|
-
|
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
|
138
|
-
#
|
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.
|
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-
|
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: []
|