credsummoner 0.1.0

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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d9c3a614f16fb15c9ba37707b6b87e834b7961573445a1416ea4a6b343310ae6
4
+ data.tar.gz: d79155dacf952e1b9974f3cfffadbc38619c1b5466c820ab9de3fd2d73c98e6b
5
+ SHA512:
6
+ metadata.gz: 19bac7a5369554246d91ef42005581f1a1321d89929f66e6b6bec89ca15d6501c21d2c1b7b6a999eca9690cea5ee76463444f149f95881fd503c7b688627d587
7
+ data.tar.gz: f63d654e84c47e50e7e9e6d5bc2cda5651e8d290b8de76d62ea0debb0fc5872f20b2aed343ec90823646853fd5de709957088bd4d10251d52b99e42e75b1aef4
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'credsummoner'
4
+ require 'optparse'
5
+ require 'tty-prompt'
6
+
7
+ duration = 12 * 60 * 60 # 12 hour session
8
+ mode = :command
9
+ region = ENV['AWS_REGION'] || 'us-east-1'
10
+ account_name = nil
11
+ role_name = nil
12
+
13
+ root_parser = OptionParser.new do |opts|
14
+ opts.banner = "Usage: credsummoner SUBCOMMAND [OPTION ...]"
15
+ #opts.separator ""
16
+ end
17
+
18
+ subcommands = {
19
+ 'get' => OptionParser.new do |opts|
20
+ opts.banner = "Usage: credsummoner get USERNAME [OPTION ...] [COMMAND ...]
21
+
22
+ Fetch temporary AWS tokens and do one of the following:
23
+
24
+ * Print environment variables to stdout if --env is specified
25
+ * Run COMMAND if specified
26
+ * Otherwise, run default user shell
27
+ "
28
+
29
+ opts.on('-d', '--duration=DURATION', 'the ttl of the session token in seconds') do |d|
30
+ duration = d
31
+ end
32
+
33
+ opts.on('-e', '--environment', 'display environment variables instead of running a command') do |e|
34
+ mode = :environment
35
+ end
36
+
37
+ opts.on('--region=REGION', 'AWS region') do |r|
38
+ region = r
39
+ end
40
+
41
+ opts.on('-a', '--account=ACCOUNT', 'AWS account alias') do |a|
42
+ account_name = a
43
+ end
44
+
45
+ opts.on('-r', '--role=ROLE', 'AWS role name') do |r|
46
+ role_name = r
47
+ end
48
+ end,
49
+ 'config' => OptionParser.new do |opts|
50
+ opts.banner = 'Usage: credsummoner config KEY VALUE
51
+
52
+ Set the configuration option KEY to VALUE.
53
+
54
+ Available configuration keys:
55
+
56
+ * okta_aws_embed_link: The embed link for the AWS application in Okta.
57
+ This link can be found in the "General" tab when viewing the AWS
58
+ application settings in the Okta admin interface.
59
+ '
60
+ end
61
+ }
62
+
63
+ root_parser.order!
64
+ subcommand = ARGV.shift
65
+ subcommands[subcommand].parse!
66
+
67
+ case subcommand
68
+ when 'get'
69
+ username = ARGV[0]
70
+
71
+ unless username
72
+ puts 'username must be specified'
73
+ puts "see 'credsummoner --help'"
74
+ exit(1)
75
+ end
76
+
77
+ unless CredSummoner::Config.exists?
78
+ puts 'CredSummoner has not yet been configured'
79
+ puts "see 'credsummoner config --help'"
80
+ exit(1)
81
+ end
82
+
83
+ prompt = TTY::Prompt.new
84
+ user = CredSummoner::Okta::User.new(username) do
85
+ password = prompt.mask('password:')
86
+ totp_token = prompt.ask('TOTP token:')
87
+ CredSummoner::Okta::Credentials.new(password, totp_token)
88
+ end
89
+ account = if account_name
90
+ user.role_map.keys.find { |a| a.name == account_name } ||
91
+ begin
92
+ puts "account '#{account_name}' is not a valid choice"
93
+ puts 'available accounts:'
94
+ user.role_map.keys.each do |acc|
95
+ puts " - #{acc.name}"
96
+ end
97
+ exit(1)
98
+ end
99
+ else
100
+ prompt.select('which account?') do |menu|
101
+ user.role_map.keys.each do |account|
102
+ menu.choice(account.to_s, account)
103
+ end
104
+ end
105
+ end
106
+ role = if role_name
107
+ user.role_map[account].find { |r| r.name == role_name } ||
108
+ begin
109
+ puts "role '#{role_name}' is not a valid choice"
110
+ puts 'available roles:'
111
+ user.role_map[account].each do |role|
112
+ puts " - #{role.name}"
113
+ end
114
+ exit(1)
115
+ end
116
+ else
117
+ prompt.select('which role?') do |menu|
118
+ user.role_map[account].each do |role|
119
+ menu.choice(role.to_s, role)
120
+ end
121
+ end
122
+ end
123
+ credentials = user.assume_role(role, duration, region)
124
+
125
+ if mode == :command
126
+ command = if ARGV.length > 1
127
+ ARGV.drop(1)
128
+ else
129
+ # Default to user's preferred shell, falling back to bash.
130
+ [ENV['SHELL']] || ['bash']
131
+ end
132
+
133
+ # Fork and use exec to spawn a child process with the AWS session
134
+ # environment variables prepared.
135
+ pid = Process.fork do
136
+ ENV['AWS_ACCESS_KEY_ID'] = credentials[:access_key_id]
137
+ ENV['AWS_SECRET_ACCESS_KEY'] = credentials[:secret_access_key]
138
+ ENV['AWS_SESSION_TOKEN'] = credentials[:session_token]
139
+ # For decorating the shell prompt.
140
+ ENV['CREDSUMMONER_AWS_ROLE'] = "#{account.name}/#{role.name}"
141
+ STDERR.puts "session expires at #{credentials[:expiration]}"
142
+ exec(*command)
143
+ end
144
+
145
+ if pid
146
+ Process.waitpid(pid)
147
+ exit($?.exitstatus) # exit with status of child process
148
+ end
149
+ elsif mode == :environment
150
+ puts "export AWS_ACCESS_KEY_ID=\"#{credentials[:access_key_id]}\""
151
+ puts "export AWS_SECRET_ACCESS_KEY=\"#{credentials[:secret_access_key]}\""
152
+ puts "export AWS_SESSION_TOKEN=\"#{credentials[:session_token]}\""
153
+ puts "export CREDSUMMONER_AWS_ROLE=\"#{account.name}/#{role.name}\""
154
+ STDERR.puts "session expires at #{credentials[:expiration]}"
155
+ end
156
+ when 'config'
157
+ key = ARGV[0]
158
+ value = ARGV[1]
159
+ config = if CredSummoner::Config.exists?
160
+ CredSummoner::Config.load
161
+ else
162
+ CredSummoner::Config.new
163
+ end
164
+ config.send("#{key}=", value)
165
+ config.save
166
+ end
@@ -0,0 +1,8 @@
1
+ require 'credsummoner/config'
2
+ require 'credsummoner/account'
3
+ require 'credsummoner/role'
4
+ require 'credsummoner/saml_assertion'
5
+ require 'credsummoner/web'
6
+ require 'credsummoner/okta/credentials'
7
+ require 'credsummoner/okta/session'
8
+ require 'credsummoner/okta/user'
@@ -0,0 +1,14 @@
1
+ module CredSummoner
2
+ class Account
3
+ attr_reader :name, :id
4
+
5
+ def initialize(name, id)
6
+ @name = name
7
+ @id = id
8
+ end
9
+
10
+ def to_s
11
+ name
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,46 @@
1
+ require 'fileutils'
2
+ require 'yaml'
3
+
4
+ module CredSummoner
5
+ class Config
6
+ attr_accessor :okta_aws_embed_link
7
+
8
+ def initialize(okta_aws_embed_link: nil)
9
+ @okta_aws_embed_link = okta_aws_embed_link
10
+ end
11
+
12
+ def self.exists?
13
+ File.exists?(config_file)
14
+ end
15
+
16
+ def self.load
17
+ if exists?
18
+ yaml = YAML.load(File.read(config_file))
19
+ Config.new(okta_aws_embed_link: yaml['okta_aws_embed_link'])
20
+ else
21
+ raise 'no config file'
22
+ end
23
+ end
24
+
25
+ def self.config_dir
26
+ "#{ENV['HOME']}/.config/credsummoner"
27
+ end
28
+
29
+ def self.config_file
30
+ "#{config_dir}/config.yml"
31
+ end
32
+
33
+ def save
34
+ FileUtils.mkdir_p(Config.config_dir)
35
+ File.open(Config.config_file, 'w', 0600) do |file|
36
+ file.puts(YAML.dump(serialize))
37
+ end
38
+ end
39
+
40
+ def serialize
41
+ {
42
+ 'okta_aws_embed_link' => okta_aws_embed_link
43
+ }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,12 @@
1
+ module CredSummoner
2
+ module Okta
3
+ class Credentials
4
+ attr_reader :password, :totp_token
5
+
6
+ def initialize(password, totp_token)
7
+ @password = password
8
+ @totp_token = totp_token
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,116 @@
1
+ require 'fileutils'
2
+
3
+ module CredSummoner
4
+ module Okta
5
+ class Session
6
+ attr_reader :username, :get_creds
7
+
8
+ def initialize(username, get_creds)
9
+ @username = username
10
+ @get_creds = get_creds
11
+ end
12
+
13
+ def cache_dir
14
+ "#{ENV['HOME']}/.cache/credsummoner"
15
+ end
16
+
17
+ def cache_file
18
+ "#{cache_dir}/okta_session_#{username}"
19
+ end
20
+
21
+ def lookup_cached_session
22
+ File.exists?(cache_file) && JSON.parse(File.read(cache_file))
23
+ end
24
+
25
+ def cache_session(session)
26
+ FileUtils.mkdir_p(cache_dir)
27
+ File.open(cache_file, 'w', 0600) do |file|
28
+ file.puts(session.to_json)
29
+ end
30
+ end
31
+
32
+ def clear!
33
+ File.delete(cache_file)
34
+ @data = nil
35
+ end
36
+
37
+ def aws_embed_uri
38
+ @aws_embed_uri ||= URI.parse(Config.load.okta_aws_embed_link)
39
+ end
40
+
41
+ def base_okta_url
42
+ "#{aws_embed_uri.scheme}://#{aws_embed_uri.host}"
43
+ end
44
+
45
+ def auth_url
46
+ "#{base_okta_url}/api/v1/authn"
47
+ end
48
+
49
+ def login(creds)
50
+ response = Web.post_json(auth_url,
51
+ username: username,
52
+ password: creds.password)
53
+
54
+ if response
55
+ status = response['status']
56
+ case status
57
+ when 'SUCCESS'
58
+ response['sessionToken']
59
+ when 'MFA_REQUIRED'
60
+ # FIXME: TOTP is the only supported factor currently.
61
+ factor = response['_embedded']['factors'].find do |factor|
62
+ factor['factorType'] == 'token:software:totp'
63
+ end
64
+ mfa(factor['id'], response['stateToken'], creds)
65
+ end
66
+ else
67
+ raise 'incorrect password'
68
+ end
69
+ end
70
+
71
+ def mfa(factor_id, state_token, creds)
72
+ response = Web.post_json("#{base_okta_url}/api/v1/authn/factors/#{factor_id}/verify",
73
+ stateToken: state_token,
74
+ passCode: creds.totp_token)
75
+ if response
76
+ response['sessionToken']
77
+ else
78
+ raise 'invalid MFA token'
79
+ end
80
+ end
81
+
82
+ def create_fresh_session
83
+ creds = get_creds.call
84
+ session_token = login(creds)
85
+ app_url_with_token = "#{aws_embed_uri.to_s}?onetimetoken=#{session_token}"
86
+ # A successful login yields a URL to redirect to and a cookie that has
87
+ # our session.
88
+ response = Web.get(app_url_with_token)
89
+ redirect_url = response['location']
90
+ # Really simple cookie parsing.
91
+ cookie = response.get_fields('set-cookie').map do |field|
92
+ field.split('; ')[0]
93
+ end.join('; ')
94
+ saml_url = Web.get(redirect_url, cookie: cookie)['location']
95
+ data = {
96
+ 'saml_url' => saml_url,
97
+ 'cookie' => cookie
98
+ }
99
+ cache_session(data)
100
+ data
101
+ end
102
+
103
+ def data
104
+ @data ||= lookup_cached_session || create_fresh_session
105
+ end
106
+
107
+ def saml_url
108
+ data['saml_url']
109
+ end
110
+
111
+ def cookie
112
+ data['cookie']
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,115 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+ require 'nokogiri'
4
+
5
+ module CredSummoner
6
+ module Okta
7
+ class User
8
+ attr_reader :username
9
+
10
+ def initialize(username, &blk)
11
+ @username = username
12
+ @get_creds = blk.to_proc
13
+ end
14
+
15
+ def session
16
+ @session ||= Session.new(username, @get_creds)
17
+ end
18
+
19
+ def saml_assertion
20
+ @saml_assertion ||=
21
+ begin
22
+ response = nil
23
+ while true
24
+ # Get the base64 encoded SAML assertion that we will need to
25
+ # send along to AWS.
26
+ response = Web.get(session.saml_url, cookie: session.cookie)
27
+ if response.code == '200'
28
+ break
29
+ else
30
+ # Cookie expired! Clear session and try again. The
31
+ # user will be prompted for credentials again.
32
+ session.clear!
33
+ end
34
+ end
35
+ saml_page = response.body
36
+ SAMLAssertion.new(Nokogiri::HTML(saml_page).at_css('form input[name=SAMLResponse]')['value'])
37
+ end
38
+ end
39
+
40
+ def role_map
41
+ @role_map ||=
42
+ begin
43
+ # Two things can happen on this sign-in page:
44
+ #
45
+ # 1) The user only has access to a single role in a single
46
+ # account, in which case they are redirected straight to the
47
+ # console for that account + role
48
+ #
49
+ # 2) The user has access to more than one role in one or more
50
+ # accounts, in which case they are presented with a page that
51
+ # lists all accounts and roles for them to choose from.
52
+ #
53
+ # For case #1, the response body will be the empty string and
54
+ # the set-cookie header will contain the account + role
55
+ # information and we will parse that.
56
+ #
57
+ # For case #2, the response body will be scraped for all the
58
+ # account + role information.
59
+ #
60
+ # In both cases we return a hash table mapping accounts to
61
+ # roles.
62
+ response = Web.post_form('https://signin.aws.amazon.com/saml',
63
+ SAMLResponse: saml_assertion.response,
64
+ RelayState: '')
65
+ if response.body.empty?
66
+ cookie = response.get_fields('set-cookie').each_with_object({}) do |field, h|
67
+ key, value = field.split('; ')[0].split('=')
68
+ h[key] = value
69
+ end
70
+ user_info = JSON.parse(URI.unescape(cookie['aws-userInfo']))
71
+ split_arn = user_info['arn'].split('/')
72
+ role_name = split_arn[1]
73
+ account_id = split_arn[0].split(':')[4]
74
+ role_arn = "arn:aws:iam::#{account_id}:role/#{role_name}"
75
+ account = Account.new(user_info['alias'], account_id)
76
+ role = Role.new(
77
+ name: role_name,
78
+ arn: role_arn,
79
+ principal_arn: saml_assertion.principal_arn_map[role_arn]
80
+ )
81
+ { account => [role] }
82
+ else
83
+ # Time for a little web scraping. Create an account -> roles mapping
84
+ # so that we can present the user with a list of roles to choose from.
85
+ role_page = response.body
86
+ html = Nokogiri::HTML(role_page)
87
+ accounts = html.css('div[class=saml-account-name]').map do |node|
88
+ # example account text we are parsing:
89
+ # Account: maestro-staging (774082247212)
90
+ parts = node.text.split(' ')
91
+ name = parts[1]
92
+ id = parts[2][1..-2] # account name is in parens, trim those off
93
+ Account.new(name, id)
94
+ end
95
+ roles = html.css('div[class=saml-account] div[class=saml-account]').map do |node|
96
+ node.css('input[name=roleIndex]').map do |field|
97
+ id = field['id']
98
+ arn = field['value']
99
+ # Extract the human readable role name.
100
+ name = node.css("label[for='#{id}']").text
101
+ Role.new(name: name, arn: arn,
102
+ principal_arn: saml_assertion.principal_arn_map[arn])
103
+ end
104
+ end
105
+ accounts.zip(roles).to_h
106
+ end
107
+ end
108
+ end
109
+
110
+ def assume_role(role, duration, region)
111
+ role.assume(saml_assertion, duration, region)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,25 @@
1
+ require 'aws-sdk-core'
2
+
3
+ class Role
4
+ attr_reader :name, :arn, :principal_arn
5
+
6
+ def initialize(name:, arn:, principal_arn:)
7
+ @name = name
8
+ @arn = arn
9
+ @principal_arn = principal_arn
10
+ end
11
+
12
+ def assume(saml, duration, region)
13
+ sts = Aws::STS::Client.new(region: region)
14
+ sts.assume_role_with_saml(
15
+ principal_arn: principal_arn,
16
+ role_arn: arn,
17
+ saml_assertion: saml.response,
18
+ duration_seconds: duration
19
+ ).credentials
20
+ end
21
+
22
+ def to_s
23
+ name
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ require 'base64'
2
+ require 'nokogiri'
3
+
4
+ module CredSummoner
5
+ class SAMLAssertion
6
+ attr_reader :response
7
+
8
+ def initialize(response)
9
+ @response = response
10
+ end
11
+
12
+ def xml_tree
13
+ @xml_tree ||= Nokogiri::XML(Base64.decode64(response))
14
+ end
15
+
16
+ # Role->Principal mapping
17
+ def principal_arn_map
18
+ @principal_arn_map ||=
19
+ begin
20
+ # The SAML document has the principal ARNs and role ARNs in
21
+ # "principal,role" pairs. So, we generate a mapping from role
22
+ # to principal for lookup later when we talk to AWS STS to
23
+ # create a session.
24
+ saml_xpath = "//saml2:Attribute[@Name='https://aws.amazon.com/SAML/Attributes/Role']/saml2:AttributeValue"
25
+ saml_namespace = 'urn:oasis:names:tc:SAML:2.0:assertion'
26
+ xml_tree.xpath(saml_xpath, saml2: saml_namespace).map do |node|
27
+ node.text.split(',').reverse
28
+ end.to_h
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ require 'json'
2
+ require 'net/http'
3
+
4
+ module CredSummoner
5
+ class Web
6
+ def self.get(url, cookie: nil)
7
+ uri = URI.parse(url)
8
+ http = Net::HTTP::new(uri.host, uri.port)
9
+ http.use_ssl = true
10
+ request = Net::HTTP::Get.new(uri.request_uri)
11
+ request['Cookie'] = cookie if cookie
12
+ http.request(request)
13
+ end
14
+
15
+ def self.post_form(url, form_data)
16
+ uri = URI.parse(url)
17
+ http = Net::HTTP::new(uri.host, uri.port)
18
+ http.use_ssl = true
19
+ request = Net::HTTP::Post.new(uri.request_uri)
20
+ request.set_form_data(form_data)
21
+ http.request(request)
22
+ end
23
+
24
+ def self.post_json(url, args)
25
+ uri = URI.parse(url)
26
+ http = Net::HTTP::new(uri.host, uri.port)
27
+ http.use_ssl = true
28
+ request = Net::HTTP::Post.new(uri.request_uri)
29
+ request.body = args.to_json
30
+ request.content_type = 'application/json'
31
+ response = http.request(request)
32
+ if response.code == '200'
33
+ JSON.parse(response.body)
34
+ else
35
+ false
36
+ end
37
+ end
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,95 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: credsummoner
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - David Thompson
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-07-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: aws-sdk-core
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.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: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: tty-prompt
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.19'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.19'
55
+ description: Retrieve temporary AWS credentials via an identity provider.
56
+ email: dthompson@vistahigherlearning.com
57
+ executables:
58
+ - credsummoner
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - bin/credsummoner
63
+ - lib/credsummoner.rb
64
+ - lib/credsummoner/account.rb
65
+ - lib/credsummoner/config.rb
66
+ - lib/credsummoner/okta/credentials.rb
67
+ - lib/credsummoner/okta/session.rb
68
+ - lib/credsummoner/okta/user.rb
69
+ - lib/credsummoner/role.rb
70
+ - lib/credsummoner/saml_assertion.rb
71
+ - lib/credsummoner/web.rb
72
+ homepage: https://github.com/vhl/credsummoner
73
+ licenses:
74
+ - GPL-3.0+
75
+ metadata: {}
76
+ post_install_message:
77
+ rdoc_options: []
78
+ require_paths:
79
+ - lib
80
+ required_ruby_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ required_rubygems_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ requirements: []
91
+ rubygems_version: 3.0.3
92
+ signing_key:
93
+ specification_version: 4
94
+ summary: Retrieve temporary AWS credentials via an identity provider
95
+ test_files: []