Skip to content

Commit 9976d78

Browse files
authored
Merge pull request #36 from nathan-v/Feature_Specified_Duration
Specified key duration, console login URLs, v0.8.0
2 parents 5b8697e + 6e4f393 commit 9976d78

File tree

8 files changed

+291
-15
lines changed

8 files changed

+291
-15
lines changed

README.md

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
[![Requirements Status](https://requires.io/github/nathan-v/aws_okta_keyman/requirements.svg?branch=master)](https://requires.io/github/nathan-v/aws_okta_keyman/requirements/?branch=master) [![Known Vulnerabilities](https://snyk.io/test/github/nathan-v/aws_okta_keyman/badge.svg)](https://snyk.io/test/github/nathan-v/aws_okta_keyman)
66

7-
![CircleCI](https://img.shields.io/circleci/build/gh/nathan-v/aws_okta_keyman)
7+
[![CircleCI](https://img.shields.io/circleci/build/gh/nathan-v/aws_okta_keyman)](https://circleci.com/gh/nathan-v/aws_okta_keyman/tree/master)
88

99
# AWS Okta Keyman
1010

@@ -114,6 +114,68 @@ aws_okta_keyman -P # Enable the password cache
114114
aws_okta_keyman -R # Reset the cached password in case of mistaken entry or password change
115115
```
116116

117+
### Command Wrapping
118+
119+
Command wrapping provides a simple way to execute any command you would like directly from
120+
Keyman where the AWS access key environment variables will be provided when starting the
121+
command. An example of this is provided here:
122+
123+
```text
124+
$ aws_okta_keyman --command "echo \$AWS_ACCESS_KEY_ID"
125+
14:06:48 (INFO) AWS Okta Keyman 🔐 v0.7.5
126+
127+
----snip----
128+
129+
14:07:17 (INFO) Assuming role: arn:aws:iam::1234567890:role/Admin
130+
14:07:17 (INFO) Wrote profile "default" to /home/nathan/.aws/credentials 💾
131+
14:07:17 (INFO) Current time is 2020-01-10 22:07:17.027964
132+
14:07:17 (INFO) Session expires at 2020-01-10 23:07:16+00:00 ⏳
133+
14:07:17 (INFO) Running requested command...
134+
135+
136+
AXXXXXXXXXXXXXXXXXXX
137+
138+
```
139+
140+
### Screen-only Key Output
141+
142+
Screen-only output for cases were the key needs to be copied
143+
elsewhere for use. This makes using the temporary keys in other apps simpler and easier.
144+
They will not be written out to the AWS credentials file when this option is specified.
145+
146+
```text
147+
$ aws_okta_keyman --screen
148+
14:13:27 (INFO) AWS Okta Keyman 🔐 v0.7.5
149+
150+
----snip----
151+
152+
153+
14:14:04 (INFO) Assuming role: arn:aws:iam::1234567890:role/Admin
154+
14:14:04 (INFO) AWS Credentials:
155+
156+
157+
AWS_ACCESS_KEY_ID = AXXXXXXXXXXXXXXXXXXX
158+
AWS_SECRET_ACCESS_KEY = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
159+
AWS_SESSION_TOKEN = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
160+
161+
162+
14:14:04 (INFO) All done! 👍
163+
```
164+
165+
### GovCloud Support
166+
AWS Okta Keyman now works with AWS GovCloud. Use the `--region` command-line option
167+
to specify the AWS region to get the keys from.
168+
169+
### Preferred Key Duration
170+
You can set a key lifetime other than the default 1 hour by setting `--duration` when calling Keyman.
171+
If AWS rejects the request for a longer duration the default 1 hour will be used instead. You can request
172+
key durations from a minimum of 15 minutes (900 seconds) or up to 12 hours (43200 seconds). These
173+
limits are enforced by AWS and are not a limitation of Keyman.
174+
175+
### AWS Console Logins
176+
AWS Console login links can optionally be generated when yo request keys with Keyman.
177+
The console login link will be output on the screen for you to use. Just provide the `--console`
178+
parameter when running Keyman.
117179

118180
### Config file .. predefined settings for you or your org
119181

@@ -196,9 +258,12 @@ App ID:
196258
14:21:58 (INFO) Config file written. Please rerun Keyman
197259
```
198260

199-
### Python Versions
261+
## Python Versions
262+
263+
Python 2.7.4+ and Python 3.5.0+ are supported.
200264

201-
Python 2.7.4+ and Python 3.5.0+ are supported
265+
Support for older Python versions will be maintained as long as is reasonable.
266+
Before support is removed a reminder/warning will be provided.
202267

203268
## Usage
204269

@@ -271,7 +336,7 @@ Selection: 0
271336
```
272337

273338

274-
### Okta Setup
339+
## Okta Setup
275340
Before you can use this tool, your Okta administrator needs to set up
276341
[Amazon/Okta integration][okta_aws_guide] using SAML roles.
277342

aws_okta_keyman/aws.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@
2525

2626
import configparser
2727
import datetime
28+
import json
2829
import logging
2930
import os
3031
import re
3132
from builtins import str
3233

3334
import boto3
35+
import botocore
3436
import bs4
3537
import requests
3638

@@ -115,7 +117,8 @@ def __init__(self,
115117
credential_path='~/.aws',
116118
profile='default',
117119
region='us-east-1',
118-
role=None):
120+
role=None,
121+
session_duration=3600):
119122
cred_dir = os.path.expanduser(credential_path)
120123
cred_file = os.path.join(cred_dir, 'credentials')
121124

@@ -141,6 +144,7 @@ def __init__(self,
141144
'Expiration': None}
142145
self.session_token = None
143146
self.role = role
147+
self.duration = session_duration
144148
self.available_roles()
145149

146150
@property
@@ -209,10 +213,23 @@ def assume_role(self, print_only=False):
209213

210214
LOG.info('Assuming role: {}'.format(self.roles[self.role]['arn']))
211215

212-
session = self.sts.assume_role_with_saml(
213-
RoleArn=self.roles[self.role]['arn'],
214-
PrincipalArn=self.roles[self.role]['principle'],
215-
SAMLAssertion=self.assertion.encode())
216+
try:
217+
session = self.sts.assume_role_with_saml(
218+
RoleArn=self.roles[self.role]['arn'],
219+
PrincipalArn=self.roles[self.role]['principle'],
220+
SAMLAssertion=self.assertion.encode(),
221+
DurationSeconds=self.duration)
222+
except botocore.exceptions.ClientError:
223+
# Try again with the default duration
224+
msg = ("Error assuming session with duration "
225+
"{}. Retrying with 3600.".format(self.duration))
226+
LOG.warning(msg)
227+
session = self.sts.assume_role_with_saml(
228+
RoleArn=self.roles[self.role]['arn'],
229+
PrincipalArn=self.roles[self.role]['principle'],
230+
SAMLAssertion=self.assertion.encode(),
231+
DurationSeconds=3600)
232+
216233
self.creds = session['Credentials']
217234

218235
if print_only:
@@ -241,6 +258,31 @@ def _print_creds(self):
241258
cred_str, self.creds['SessionToken'])
242259
LOG.info("AWS Credentials: \n\n\n{}\n\n".format(cred_str))
243260

261+
def generate_aws_console_url(self, issuer):
262+
""" Generate a URL for logging into the AWS console with the current
263+
session key
264+
265+
Returns: string URL for console login
266+
"""
267+
creds = {'sessionId': self.creds['AccessKeyId'],
268+
'sessionKey': self.creds['SecretAccessKey'],
269+
'sessionToken': self.creds['SessionToken']}
270+
271+
params = {'Action': 'getSigninToken',
272+
'SessionDuration': self.duration,
273+
'Session': json.dumps(creds)}
274+
275+
token_url = "https://signin.aws.amazon.com/federation"
276+
resp = requests.get(token_url, params=params)
277+
token = resp.json()['SigninToken']
278+
279+
console_url = 'https%3A//console.aws.amazon.com/'
280+
params = ("?Action=login&Issuer={}&Destination={}"
281+
"&SigninToken={}").format(issuer, console_url, token)
282+
283+
url = "https://signin.aws.amazon.com/federation{}".format(params)
284+
return url
285+
244286
def export_creds_to_var_string(self):
245287
""" Export the current credentials as environment vaiables
246288
"""

aws_okta_keyman/config.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,25 @@ def __init__(self, argv):
5151
self.command = None
5252
self.screen = None
5353
self.region = None
54+
self.duration = None
55+
self.console = None
5456

5557
if len(argv) > 1:
5658
if argv[1] == 'config':
5759
self.interactive_config()
5860
sys.exit(0)
5961

62+
def full_app_url(self):
63+
""" Retrieve the full Okta app URL. """
64+
okta_domain = 'okta.com'
65+
if self.oktapreview:
66+
okta_domain = 'oktapreview.com'
67+
full_url = "https://{}.{}/{}".format(
68+
self.org,
69+
okta_domain,
70+
self.appid)
71+
return full_url
72+
6073
def set_appid_from_account_id(self, account_id):
6174
"""Take an account ID (list index) and sets the appid based on that."""
6275
self.appid = self.accounts[account_id]['appid']
@@ -67,6 +80,12 @@ def validate(self):
6780
err = ("The parameter org must be provided in the config file "
6881
"or as an argument")
6982
raise ValueError(err)
83+
duration = getattr(self, 'duration')
84+
if duration:
85+
if duration > 43200 or duration < 900:
86+
err = ("The parameter duration must be between 900 and 43200 "
87+
"(15m to 12h).")
88+
raise ValueError(err)
7089

7190
if self.region is None:
7291
self.region = 'us-east-1'
@@ -253,6 +272,21 @@ def optional_args(optional_args):
253272
'AWS region to use for calls. '
254273
'Required for GovCloud.'
255274
))
275+
optional_args.add_argument('-du', '--duration', type=int,
276+
help=(
277+
'AWS API Key duration to request. '
278+
'If the supplied value is rejected '
279+
'by AWS the default of 3600s (one '
280+
'hour) will be used.'
281+
),
282+
default=3600)
283+
optional_args.add_argument('-co', '--console',
284+
action='store_true', help=(
285+
'Output AWS Console URLs to log in '
286+
'and use the web conle with the '
287+
'selected role..'
288+
),
289+
default=False)
256290

257291
@staticmethod
258292
def read_yaml(filename, raise_on_error=False):

aws_okta_keyman/keyman.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,8 @@ def start_session(self):
355355
self.log.info("Starting AWS session for {}".format(
356356
self.config.region))
357357
session = aws.Session(assertion, profile=self.config.name,
358-
role=self.role, region=self.config.region)
358+
role=self.role, region=self.config.region,
359+
session_duration=self.config.duration)
359360

360361
except xml.etree.ElementTree.ParseError:
361362
self.log.error('Could not find any Role in the SAML assertion')
@@ -425,5 +426,10 @@ def wrap_up(self, session):
425426
)
426427
self.log.info("Running requested command...\n\n")
427428
os.system(command_string)
429+
elif self.config.console:
430+
app_url = self.config.full_app_url()
431+
url = session.generate_aws_console_url(app_url)
432+
self.log.info("AWS Console URL: {}".format(url))
433+
428434
else:
429435
self.log.info('All done! 👍')

aws_okta_keyman/metadata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,5 @@
1414
# Copyright 2018 Nathan V
1515
"""Package metadata."""
1616

17-
__version__ = '0.7.5'
17+
__version__ = '0.8.0'
1818
__desc__ = 'AWS Okta Keyman'

aws_okta_keyman/test/aws_test.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import sys
55
import unittest
66

7+
import botocore
8+
79
from aws_okta_keyman import aws
810

911
if sys.version_info[0] < 3: # Python 2
@@ -236,6 +238,13 @@ def test_assume_role(self, mock_write):
236238
mock_write.assert_has_calls([
237239
mock.call()
238240
])
241+
session.sts.assert_has_calls([
242+
mock.call.assume_role_with_saml(
243+
RoleArn='',
244+
PrincipalArn='',
245+
SAMLAssertion=mock.ANY,
246+
DurationSeconds=3600)
247+
])
239248

240249
@mock.patch('aws_okta_keyman.aws.Session._write')
241250
def test_assume_role_multiple(self, mock_write):
@@ -311,6 +320,45 @@ def test_assume_role_print(self, mock_write, mock_print):
311320
assert not mock_write.called
312321
assert mock_print.called
313322

323+
@mock.patch('aws_okta_keyman.aws.Session._write')
324+
def test_assume_role_duration_rejected(self, mock_write):
325+
mock_write.return_value = None
326+
assertion = mock.Mock()
327+
assertion.roles.return_value = [{'arn': '', 'principle': ''}]
328+
session = aws.Session('BogusAssertion')
329+
session.duration = 1000000
330+
session.roles = [{'arn': '', 'principle': ''}]
331+
session.assertion = assertion
332+
sts = {'Credentials':
333+
{'AccessKeyId': 'AKI',
334+
'SecretAccessKey': 'squirrel',
335+
'SessionToken': 'token',
336+
'Expiration': 'never'
337+
}}
338+
session.sts = mock.Mock()
339+
err_mock = mock.MagicMock()
340+
err = botocore.exceptions.ClientError(err_mock, err_mock)
341+
session.sts.assume_role_with_saml.side_effect = [err, sts]
342+
343+
session.assume_role()
344+
345+
self.assertEqual('AKI', session.creds['AccessKeyId'])
346+
self.assertEqual('squirrel', session.creds['SecretAccessKey'])
347+
self.assertEqual('token', session.creds['SessionToken'])
348+
self.assertEqual('never', session.creds['Expiration'])
349+
session.sts.assert_has_calls([
350+
mock.call.assume_role_with_saml(
351+
RoleArn='',
352+
PrincipalArn='',
353+
SAMLAssertion=mock.ANY,
354+
DurationSeconds=1000000),
355+
mock.call.assume_role_with_saml(
356+
RoleArn='',
357+
PrincipalArn='',
358+
SAMLAssertion=mock.ANY,
359+
DurationSeconds=3600),
360+
])
361+
314362
@mock.patch('aws_okta_keyman.aws.LOG')
315363
def test_print_creds(self, log_mock):
316364
session = aws.Session('BogusAssertion')
@@ -327,6 +375,38 @@ def test_print_creds(self, log_mock):
327375
mock.call.info(expected)
328376
])
329377

378+
@mock.patch('aws_okta_keyman.aws.requests')
379+
def test_generate_aws_console_url(self, requests_mock):
380+
session = aws.Session('BogusAssertion')
381+
session.duration = 3600
382+
session.creds = {'AccessKeyId': 'AKI',
383+
'SecretAccessKey': 'squirrel',
384+
'SessionToken': 'token',
385+
'Expiration': 'never'
386+
}
387+
resp_mock = mock.MagicMock()
388+
resp_mock.json.return_value = {'SigninToken': 'baz'}
389+
requests_mock.get.return_value = resp_mock
390+
391+
issuer = 'https://ex.okta.com/foo/bar'
392+
ret = session.generate_aws_console_url(issuer)
393+
394+
expected = (
395+
"https://signin.aws.amazon.com/federation?Action=login&Issuer="
396+
"https://ex.okta.com/foo/bar&Destination="
397+
"https%3A//console.aws.amazon.com/&SigninToken=baz")
398+
self.assertEqual(ret, expected)
399+
# mock.ANY required for the session due to Python 3.5 behavior
400+
requests_mock.assert_has_calls([
401+
mock.call.get(
402+
'https://signin.aws.amazon.com/federation',
403+
params={
404+
'Action': 'getSigninToken',
405+
'SessionDuration': 3600,
406+
'Session': mock.ANY}),
407+
mock.call.get().json()
408+
])
409+
330410
def test_export_creds_to_var_string(self):
331411
session = aws.Session('BogusAssertion')
332412
expected = (

0 commit comments

Comments
 (0)