Skip to content

Commit

Permalink
Merge pull request #36 from nathan-v/Feature_Specified_Duration
Browse files Browse the repository at this point in the history
Specified key duration, console login URLs, v0.8.0
  • Loading branch information
nathan-v authored Jan 15, 2020
2 parents 5b8697e + 6e4f393 commit 9976d78
Show file tree
Hide file tree
Showing 8 changed files with 291 additions and 15 deletions.
73 changes: 69 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

[![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)

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

# AWS Okta Keyman

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

### Command Wrapping

Command wrapping provides a simple way to execute any command you would like directly from
Keyman where the AWS access key environment variables will be provided when starting the
command. An example of this is provided here:

```text
$ aws_okta_keyman --command "echo \$AWS_ACCESS_KEY_ID"
14:06:48 (INFO) AWS Okta Keyman 🔐 v0.7.5
----snip----
14:07:17 (INFO) Assuming role: arn:aws:iam::1234567890:role/Admin
14:07:17 (INFO) Wrote profile "default" to /home/nathan/.aws/credentials 💾
14:07:17 (INFO) Current time is 2020-01-10 22:07:17.027964
14:07:17 (INFO) Session expires at 2020-01-10 23:07:16+00:00 ⏳
14:07:17 (INFO) Running requested command...
AXXXXXXXXXXXXXXXXXXX
```

### Screen-only Key Output

Screen-only output for cases were the key needs to be copied
elsewhere for use. This makes using the temporary keys in other apps simpler and easier.
They will not be written out to the AWS credentials file when this option is specified.

```text
$ aws_okta_keyman --screen
14:13:27 (INFO) AWS Okta Keyman 🔐 v0.7.5
----snip----
14:14:04 (INFO) Assuming role: arn:aws:iam::1234567890:role/Admin
14:14:04 (INFO) AWS Credentials:
AWS_ACCESS_KEY_ID = AXXXXXXXXXXXXXXXXXXX
AWS_SECRET_ACCESS_KEY = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AWS_SESSION_TOKEN = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
14:14:04 (INFO) All done! 👍
```

### GovCloud Support
AWS Okta Keyman now works with AWS GovCloud. Use the `--region` command-line option
to specify the AWS region to get the keys from.

### Preferred Key Duration
You can set a key lifetime other than the default 1 hour by setting `--duration` when calling Keyman.
If AWS rejects the request for a longer duration the default 1 hour will be used instead. You can request
key durations from a minimum of 15 minutes (900 seconds) or up to 12 hours (43200 seconds). These
limits are enforced by AWS and are not a limitation of Keyman.

### AWS Console Logins
AWS Console login links can optionally be generated when yo request keys with Keyman.
The console login link will be output on the screen for you to use. Just provide the `--console`
parameter when running Keyman.

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

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

### Python Versions
## Python Versions

Python 2.7.4+ and Python 3.5.0+ are supported.

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

## Usage

Expand Down Expand Up @@ -271,7 +336,7 @@ Selection: 0
```


### Okta Setup
## Okta Setup
Before you can use this tool, your Okta administrator needs to set up
[Amazon/Okta integration][okta_aws_guide] using SAML roles.

Expand Down
52 changes: 47 additions & 5 deletions aws_okta_keyman/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@

import configparser
import datetime
import json
import logging
import os
import re
from builtins import str

import boto3
import botocore
import bs4
import requests

Expand Down Expand Up @@ -115,7 +117,8 @@ def __init__(self,
credential_path='~/.aws',
profile='default',
region='us-east-1',
role=None):
role=None,
session_duration=3600):
cred_dir = os.path.expanduser(credential_path)
cred_file = os.path.join(cred_dir, 'credentials')

Expand All @@ -141,6 +144,7 @@ def __init__(self,
'Expiration': None}
self.session_token = None
self.role = role
self.duration = session_duration
self.available_roles()

@property
Expand Down Expand Up @@ -209,10 +213,23 @@ def assume_role(self, print_only=False):

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

session = self.sts.assume_role_with_saml(
RoleArn=self.roles[self.role]['arn'],
PrincipalArn=self.roles[self.role]['principle'],
SAMLAssertion=self.assertion.encode())
try:
session = self.sts.assume_role_with_saml(
RoleArn=self.roles[self.role]['arn'],
PrincipalArn=self.roles[self.role]['principle'],
SAMLAssertion=self.assertion.encode(),
DurationSeconds=self.duration)
except botocore.exceptions.ClientError:
# Try again with the default duration
msg = ("Error assuming session with duration "
"{}. Retrying with 3600.".format(self.duration))
LOG.warning(msg)
session = self.sts.assume_role_with_saml(
RoleArn=self.roles[self.role]['arn'],
PrincipalArn=self.roles[self.role]['principle'],
SAMLAssertion=self.assertion.encode(),
DurationSeconds=3600)

self.creds = session['Credentials']

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

def generate_aws_console_url(self, issuer):
""" Generate a URL for logging into the AWS console with the current
session key
Returns: string URL for console login
"""
creds = {'sessionId': self.creds['AccessKeyId'],
'sessionKey': self.creds['SecretAccessKey'],
'sessionToken': self.creds['SessionToken']}

params = {'Action': 'getSigninToken',
'SessionDuration': self.duration,
'Session': json.dumps(creds)}

token_url = "https://signin.aws.amazon.com/federation"
resp = requests.get(token_url, params=params)
token = resp.json()['SigninToken']

console_url = 'https%3A//console.aws.amazon.com/'
params = ("?Action=login&Issuer={}&Destination={}"
"&SigninToken={}").format(issuer, console_url, token)

url = "https://signin.aws.amazon.com/federation{}".format(params)
return url

def export_creds_to_var_string(self):
""" Export the current credentials as environment vaiables
"""
Expand Down
34 changes: 34 additions & 0 deletions aws_okta_keyman/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,25 @@ def __init__(self, argv):
self.command = None
self.screen = None
self.region = None
self.duration = None
self.console = None

if len(argv) > 1:
if argv[1] == 'config':
self.interactive_config()
sys.exit(0)

def full_app_url(self):
""" Retrieve the full Okta app URL. """
okta_domain = 'okta.com'
if self.oktapreview:
okta_domain = 'oktapreview.com'
full_url = "https://{}.{}/{}".format(
self.org,
okta_domain,
self.appid)
return full_url

def set_appid_from_account_id(self, account_id):
"""Take an account ID (list index) and sets the appid based on that."""
self.appid = self.accounts[account_id]['appid']
Expand All @@ -67,6 +80,12 @@ def validate(self):
err = ("The parameter org must be provided in the config file "
"or as an argument")
raise ValueError(err)
duration = getattr(self, 'duration')
if duration:
if duration > 43200 or duration < 900:
err = ("The parameter duration must be between 900 and 43200 "
"(15m to 12h).")
raise ValueError(err)

if self.region is None:
self.region = 'us-east-1'
Expand Down Expand Up @@ -253,6 +272,21 @@ def optional_args(optional_args):
'AWS region to use for calls. '
'Required for GovCloud.'
))
optional_args.add_argument('-du', '--duration', type=int,
help=(
'AWS API Key duration to request. '
'If the supplied value is rejected '
'by AWS the default of 3600s (one '
'hour) will be used.'
),
default=3600)
optional_args.add_argument('-co', '--console',
action='store_true', help=(
'Output AWS Console URLs to log in '
'and use the web conle with the '
'selected role..'
),
default=False)

@staticmethod
def read_yaml(filename, raise_on_error=False):
Expand Down
8 changes: 7 additions & 1 deletion aws_okta_keyman/keyman.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,8 @@ def start_session(self):
self.log.info("Starting AWS session for {}".format(
self.config.region))
session = aws.Session(assertion, profile=self.config.name,
role=self.role, region=self.config.region)
role=self.role, region=self.config.region,
session_duration=self.config.duration)

except xml.etree.ElementTree.ParseError:
self.log.error('Could not find any Role in the SAML assertion')
Expand Down Expand Up @@ -425,5 +426,10 @@ def wrap_up(self, session):
)
self.log.info("Running requested command...\n\n")
os.system(command_string)
elif self.config.console:
app_url = self.config.full_app_url()
url = session.generate_aws_console_url(app_url)
self.log.info("AWS Console URL: {}".format(url))

else:
self.log.info('All done! 👍')
2 changes: 1 addition & 1 deletion aws_okta_keyman/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@
# Copyright 2018 Nathan V
"""Package metadata."""

__version__ = '0.7.5'
__version__ = '0.8.0'
__desc__ = 'AWS Okta Keyman'
80 changes: 80 additions & 0 deletions aws_okta_keyman/test/aws_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import sys
import unittest

import botocore

from aws_okta_keyman import aws

if sys.version_info[0] < 3: # Python 2
Expand Down Expand Up @@ -236,6 +238,13 @@ def test_assume_role(self, mock_write):
mock_write.assert_has_calls([
mock.call()
])
session.sts.assert_has_calls([
mock.call.assume_role_with_saml(
RoleArn='',
PrincipalArn='',
SAMLAssertion=mock.ANY,
DurationSeconds=3600)
])

@mock.patch('aws_okta_keyman.aws.Session._write')
def test_assume_role_multiple(self, mock_write):
Expand Down Expand Up @@ -311,6 +320,45 @@ def test_assume_role_print(self, mock_write, mock_print):
assert not mock_write.called
assert mock_print.called

@mock.patch('aws_okta_keyman.aws.Session._write')
def test_assume_role_duration_rejected(self, mock_write):
mock_write.return_value = None
assertion = mock.Mock()
assertion.roles.return_value = [{'arn': '', 'principle': ''}]
session = aws.Session('BogusAssertion')
session.duration = 1000000
session.roles = [{'arn': '', 'principle': ''}]
session.assertion = assertion
sts = {'Credentials':
{'AccessKeyId': 'AKI',
'SecretAccessKey': 'squirrel',
'SessionToken': 'token',
'Expiration': 'never'
}}
session.sts = mock.Mock()
err_mock = mock.MagicMock()
err = botocore.exceptions.ClientError(err_mock, err_mock)
session.sts.assume_role_with_saml.side_effect = [err, sts]

session.assume_role()

self.assertEqual('AKI', session.creds['AccessKeyId'])
self.assertEqual('squirrel', session.creds['SecretAccessKey'])
self.assertEqual('token', session.creds['SessionToken'])
self.assertEqual('never', session.creds['Expiration'])
session.sts.assert_has_calls([
mock.call.assume_role_with_saml(
RoleArn='',
PrincipalArn='',
SAMLAssertion=mock.ANY,
DurationSeconds=1000000),
mock.call.assume_role_with_saml(
RoleArn='',
PrincipalArn='',
SAMLAssertion=mock.ANY,
DurationSeconds=3600),
])

@mock.patch('aws_okta_keyman.aws.LOG')
def test_print_creds(self, log_mock):
session = aws.Session('BogusAssertion')
Expand All @@ -327,6 +375,38 @@ def test_print_creds(self, log_mock):
mock.call.info(expected)
])

@mock.patch('aws_okta_keyman.aws.requests')
def test_generate_aws_console_url(self, requests_mock):
session = aws.Session('BogusAssertion')
session.duration = 3600
session.creds = {'AccessKeyId': 'AKI',
'SecretAccessKey': 'squirrel',
'SessionToken': 'token',
'Expiration': 'never'
}
resp_mock = mock.MagicMock()
resp_mock.json.return_value = {'SigninToken': 'baz'}
requests_mock.get.return_value = resp_mock

issuer = 'https://ex.okta.com/foo/bar'
ret = session.generate_aws_console_url(issuer)

expected = (
"https://signin.aws.amazon.com/federation?Action=login&Issuer="
"https://ex.okta.com/foo/bar&Destination="
"https%3A//console.aws.amazon.com/&SigninToken=baz")
self.assertEqual(ret, expected)
# mock.ANY required for the session due to Python 3.5 behavior
requests_mock.assert_has_calls([
mock.call.get(
'https://signin.aws.amazon.com/federation',
params={
'Action': 'getSigninToken',
'SessionDuration': 3600,
'Session': mock.ANY}),
mock.call.get().json()
])

def test_export_creds_to_var_string(self):
session = aws.Session('BogusAssertion')
expected = (
Expand Down
Loading

0 comments on commit 9976d78

Please sign in to comment.