credsummoner 0.1.0

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