├── tests ├── core │ ├── __init__.py │ ├── test_saml.py │ ├── test_prompt.py │ ├── test_fetcher.py │ ├── test_tty.py │ └── test_okta.py ├── commands │ ├── __init__.py │ ├── test_base.py │ ├── test_getroles.py │ └── test_authenticate.py ├── fixtures │ ├── .awsoktaprocessor │ └── userhome │ │ └── .awsoktaprocessor ├── MFA_WAITING_RESPONSE ├── AUTH_MFA_PUSH_RESPONSE ├── AUTH_MFA_TOTP_RESPONSE ├── AUTH_MFA_YUBICO_HARDWARE_RESPONSE ├── AUTH_TOKEN_RESPONSE ├── SESSION_RESPONSE ├── AUTH_MFA_MULTIPLE_RESPONSE ├── __init__.py ├── test_cli.py ├── APPLICATIONS_RESPONSE ├── test_base.py ├── SIGN_IN_RESPONSE └── SAML_RESPONSE ├── aws_okta_processor ├── core │ ├── __init__.py │ ├── prompt.py │ ├── tty.py │ ├── saml.py │ ├── fetcher.py │ └── okta.py ├── __main__.py ├── commands │ ├── __init__.py │ ├── base.py │ ├── authenticate.py │ └── getroles.py ├── __init__.py └── cli.py ├── pytest.ini ├── MANIFEST.in ├── .editorconfig ├── setup.cfg ├── .pre-commit-config.yaml ├── PUBLISHING.md ├── Makefile ├── pyproject.toml ├── LICENSE ├── .github └── workflows │ └── build.yml ├── .gitignore ├── CODE_OF_CONDUCT.md └── README.rst /tests/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aws_okta_processor/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -p no:warnings 3 | junit_family = xunit1 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include pytest.ini 4 | include Pipfile 5 | include Pipfile.lock 6 | include tests/* -------------------------------------------------------------------------------- /aws_okta_processor/__main__.py: -------------------------------------------------------------------------------- 1 | """This module is the main entry point for the CLI.""" 2 | 3 | from .cli import main 4 | 5 | main() 6 | -------------------------------------------------------------------------------- /tests/fixtures/.awsoktaprocessor: -------------------------------------------------------------------------------- 1 | [defaults] 2 | pass=okta-pass1 3 | 4 | [authenticate] 5 | user=okta-user1 6 | option1=okta_option1_override2 -------------------------------------------------------------------------------- /aws_okta_processor/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """Module to import all the commands.""" 2 | 3 | from . import authenticate # noqa 4 | from . import getroles # noqa 5 | -------------------------------------------------------------------------------- /tests/fixtures/userhome/.awsoktaprocessor: -------------------------------------------------------------------------------- 1 | [defaults] 2 | environment=okta-env1 3 | 4 | [authenticate] 5 | organization=org1-from-home 6 | option1=okta_option1_override -------------------------------------------------------------------------------- /tests/MFA_WAITING_RESPONSE: -------------------------------------------------------------------------------- 1 | {"stateToken": "state_token", "factorResult": "WAITING", "_links": {"next": {"href": "https://organization.okta.com/api/v1/authn/factors/id/lifecycle/activate/poll"}}} -------------------------------------------------------------------------------- /tests/AUTH_MFA_PUSH_RESPONSE: -------------------------------------------------------------------------------- 1 | {"status":"MFA_REQUIRED", "stateToken": "state_token", "_embedded": {"factors": [{"factorType": "push", "provider": "OKTA", "_links": {"verify": {"href": "https://organization.okta.com/api/v1/authn/factors/id/verify"}}}]}} -------------------------------------------------------------------------------- /tests/AUTH_MFA_TOTP_RESPONSE: -------------------------------------------------------------------------------- 1 | {"status":"MFA_REQUIRED", "stateToken": "state_token", "_embedded": {"factors": [{"factorType": "token:software:totp", "provider": "GOOGLE", "_links": {"verify": {"href": "https://organization.okta.com/api/v1/authn/factors/id/verify"}}}]}} -------------------------------------------------------------------------------- /tests/AUTH_MFA_YUBICO_HARDWARE_RESPONSE: -------------------------------------------------------------------------------- 1 | {"status":"MFA_REQUIRED", "stateToken": "state_token", "_embedded": {"factors": [{"factorType": "token:hardware", "provider": "YUBICO", "_links": {"verify": {"href": "https://organization.okta.com/api/v1/authn/factors/id/verify"}}}]}} -------------------------------------------------------------------------------- /tests/AUTH_TOKEN_RESPONSE: -------------------------------------------------------------------------------- 1 | {"expiresAt":"2019-04-09T20:17:42.000Z","status":"SUCCESS","sessionToken":"single_use_token","_embedded":{"user":{"id":"foo","profile":{"login":"user@domain.tld","firstName":"foo","lastName":"bar","locale":"en","timeZone":"America/Los_Angeles"}}}} -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*.py] 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = false 12 | insert_final_newline = false 13 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = aws-okta-processor 3 | version = attr: aws_okta_processor.__version__ 4 | description = Resource for fetching AWS Role credentials from Okta. 5 | author = Cloud Platform Solutions 6 | author_email = cp-solutions@godaddy.com 7 | url = https://github.com/godaddy/aws-okta-processor 8 | license = MIT License 9 | classifiers = 10 | Programming Language :: Python :: 3.9 11 | 12 | [options] 13 | py_modules = aws_okta_processor 14 | python_requires = >= 3.9 15 | 16 | [flake8] 17 | ignore = E203, W503, E402 18 | max-line-length = 88 19 | 20 | [pylint] 21 | disable = R0801 22 | 23 | [mypy] 24 | ignore_import_untyped = True -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.3.0 4 | hooks: 5 | - id: trailing-whitespace 6 | args: [ --markdown-linebreak-ext=md ] 7 | - id: end-of-file-fixer 8 | - id: check-json 9 | - id: check-xml 10 | - id: check-toml 11 | - id: detect-private-key 12 | - id: forbid-new-submodules 13 | - id: mixed-line-ending 14 | - id: check-added-large-files 15 | - id: check-symlinks 16 | - id: check-merge-conflict 17 | - id: fix-byte-order-marker 18 | - repo: https://github.com/godaddy/tartufo 19 | rev: stable 20 | hooks: 21 | - id: tartufo 22 | -------------------------------------------------------------------------------- /tests/SESSION_RESPONSE: -------------------------------------------------------------------------------- 1 | {"id": "session_token", "userId": "bar", "login": "user@domain.tld", "createdAt": "2019-04-08T18:37:43.000Z", "expiresAt": "2019-04-09T06:37:43.000Z", "status": "ACTIVE", "lastPasswordVerification": "2019-04-08T18:37:43.000Z", "lastFactorVerification": null, "amr": ["pwd"], "idp": {"id": "foo", "type": "bar"}, "mfaActive": false, "_links": {"self": {"href": "https://organization.okta.com/api/v1/sessions/me", "hints": {"allow": ["GET", "DELETE"]}}, "refresh": {"href": "https://organization.okta.com/api/v1/sessions/me/lifecycle/refresh", "hints": {"allow": ["POST"]}}, "user": {"name": "Foo", "href": "https://organization.okta.com/api/v1/users/me", "hints": {"allow": ["GET"]}}}} -------------------------------------------------------------------------------- /PUBLISHING.md: -------------------------------------------------------------------------------- 1 | # Maintainer Publishing Steps 2 | 1. Squash and merge PR after unit tests pass and approvals then delete the branch. 3 | 2. Using an IDE pull in head of remote master branch to local. 4 | 3. Increment version stored in [`aws_okta_processor/__init__.py`](aws_okta_processor/__init__.py). Use the latest semantic versioning standard found [here](https://semver.org/) as a guide. 5 | 4. Commit version change to local master branch then push to remote with `git push origin master`. 6 | 5. Create a tag matching current version and annotate with attributed changes: 7 | ``` 8 | git tag -a v 9 | 10 | - @person did x y z 11 | ``` 12 | 6. Push tag to remote with `git push origin v`. -------------------------------------------------------------------------------- /tests/AUTH_MFA_MULTIPLE_RESPONSE: -------------------------------------------------------------------------------- 1 | {"status":"MFA_REQUIRED", "stateToken": "state_token", "_embedded": {"factors": [ 2 | {"factorType": "token:software:totp", "provider": "GOOGLE", "_links": {"verify": {"href": "https://organization.okta.com/api/v1/authn/factors/id/verify"}}}, 3 | {"factorType": "push", "provider": "OKTA", "_links": {"verify": {"href": "https://organization.okta.com/api/v1/authn/factors/id/verify"}}}, 4 | {"factorType": "token:software:totp", "provider": "OKTA", "_links": {"verify": {"href": "https://organization.okta.com/api/v1/authn/factors/id/verify"}}}, 5 | {"factorType": "token:hardware", "provider": "YUBICO", "_links": {"verify": {"href": "https://organization.okta.com/api/v1/authn/factors/id/verify"}}} 6 | ]}} -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | export PATH=$$(python3 -m site --user-base)/bin:$$PATH 3 | pip install poetry --force 4 | 5 | poetry update -vvv 6 | poetry install --remove-untracked -vvv 7 | poetry env info 8 | 9 | format: 10 | poetry run black --skip-magic-trailing-comma --preview aws_okta_processor 11 | 12 | lint: 13 | poetry run pylint aws_okta_processor 14 | poetry run flake8 aws_okta_processor 15 | poetry run mypy aws_okta_processor 16 | 17 | test: 18 | export PYTHONPATH=".:aws_okta_processor/" 19 | poetry run py.test --cov-config .coveragerc --verbose --cov-report term --cov-report xml --cov=aws_okta_processor 20 | 21 | build: 22 | poetry build 23 | 24 | publish: build 25 | poetry config pypi-token.pypi $$PYPI_TOKEN && poetry publish 26 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from aws_okta_processor.commands.base import Base 4 | 5 | FIXTURE_PATH = os.path.join(os.path.dirname(__file__), 'fixtures') 6 | 7 | 8 | class TestCommand(Base): 9 | def __init__(self, options): 10 | super().__init__(self, options) 11 | 12 | def get_configuration(self, options=None): 13 | return {} 14 | 15 | 16 | def get_fixture(path): 17 | return os.path.join(FIXTURE_PATH, path) 18 | 19 | 20 | def expand_user_side_effect(*args): 21 | return args[0].replace('~/', get_fixture('userhome/')) 22 | 23 | 24 | def get_os_exists_side_effect(effects): 25 | def side_effect(*args): 26 | if args[0] in effects: 27 | return effects[args[0]] 28 | raise RuntimeError('os_exists path "%s" not handled' % args[0]) 29 | return side_effect 30 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from aws_okta_processor import cli 5 | 6 | from unittest.mock import patch 7 | from unittest import TestCase 8 | 9 | class TestCli(TestCase): 10 | def test_main_should_run_authenticate(self): 11 | sys.argv = ["aws-okta-processor", "authenticate"] 12 | with patch("aws_okta_processor.commands.authenticate.Authenticate.run") as mock_run: 13 | cli.main() 14 | mock_run.assert_called_once() 15 | 16 | def test_main_should_run_getroles(self): 17 | sys.argv = ["aws-okta-processor", "get-roles"] 18 | with patch("aws_okta_processor.commands.getroles.GetRoles.run") as mock_run: 19 | cli.main() 20 | mock_run.assert_called_once() 21 | 22 | def test_main_should_raise_exception_on_missing_command(self): 23 | sys.argv = ["aws-okta-processor", "not-found"] 24 | self.assertRaises(SystemExit, cli.main) 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "aws-okta-processor" 3 | version = "1.10.0" 4 | description = "Resource for fetching AWS Role credentials from Okta" 5 | authors = ["Cloud Platform Solutions "] 6 | readme = "README.rst" 7 | repository = "https://github.com/godaddy/aws-okta-processor" 8 | 9 | [tool.poetry.scripts] 10 | aws-okta-processor = "aws_okta_processor.cli:main" 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.9" 14 | docopt = ">=0.6.2" 15 | requests = ">=2.21.0" 16 | boto3 = ">=1.9.134" 17 | beautifulsoup4 = ">=4.11.1" 18 | contextlib2 = ">=0.5.5" 19 | six = ">=1.12.0" 20 | defusedxml = ">=0.7.1" 21 | tomlkit = "^0.13.2" 22 | 23 | [tool.poetry.group.dev.dependencies] 24 | pytest = "^8.3.3" 25 | mock = "^5.1.0" 26 | pytest-cov = "^6.0.0" 27 | readme-renderer = "^44.0" 28 | docutils = "^0.21.2" 29 | flake8 = "^7.1.1" 30 | responses = "^0.25.3" 31 | atomicwrites = "^1.4.1" 32 | importlib-metadata = "^8.5.0" 33 | typing-extensions = "^4.12.2" 34 | black = "^24.10.0" 35 | mypy = "^1.13.0" 36 | pylint = "^3.3.1" 37 | 38 | [build-system] 39 | requires = ["poetry-core>=1.0.0"] 40 | build-backend = "poetry.core.masonry.api" 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 GoDaddy Operating Company, LLC. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | tags: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | Run: 13 | name: ${{ matrix.platform }} Python ${{ matrix.python-version }} Run 14 | runs-on: ${{ matrix.platform }} 15 | strategy: 16 | matrix: 17 | platform: [ ubuntu-latest, macos-13, windows-latest ] 18 | python-version: [ "3.9", "3.10", "3.11", "3.12"] 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Setup Python 22 | uses: actions/setup-python@v2 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | architecture: x64 26 | - name: Init Environment 27 | run: make init 28 | - name: Lint 29 | run: make lint 30 | - name: Test 31 | run: make test 32 | - name: Package Publish 33 | if: startsWith(matrix.platform, 'ubuntu') && matrix.python-version == '3.9' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 34 | env: 35 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 36 | run: 37 | make publish 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | include/ 11 | env/ 12 | test_env/ 13 | build/ 14 | develop-eggs/ 15 | dist*/ 16 | downloads/ 17 | eggs/ 18 | rpms/ 19 | .eggs/ 20 | lib64/ 21 | lib/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | .spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests*.xml 46 | coverage*.xml 47 | results/ 48 | *,cover 49 | .pytest_cache/* 50 | junit.xml 51 | report.xml 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # PyBuilder 64 | target/ 65 | 66 | .metadata 67 | 68 | # Intellij 69 | .idea/ 70 | *.iml 71 | *.iws 72 | 73 | # Mac 74 | .DS_Store 75 | 76 | # VS Code 77 | .vscode/ 78 | 79 | .venv 80 | /poetry.lock 81 | /.python-version 82 | -------------------------------------------------------------------------------- /tests/APPLICATIONS_RESPONSE: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "00ub0oNGTSWTBKOLGLNR", 4 | "label": "AWS", 5 | "linkUrl": "https://organization.okta.com/home/amazon_aws/0oa3omz2i9XRNSRIHBZO/270", 6 | "logoUrl": "https://organization.okta.com/img/logos/amazon-aws.png", 7 | "appName": "amazon_aws", 8 | "appInstanceId": "0oa3omz2i9XRNSRIHBZO", 9 | "appAssignmentId": "0ua3omz7weMMMQJERBKY", 10 | "credentialsSetup": false, 11 | "hidden": false, 12 | "sortOrder": 0 13 | }, 14 | { 15 | "id": "00ub0oNGTSWTBKOLGLNR", 16 | "label": "AWS GOV", 17 | "linkUrl": "https://organization.okta.com/home/amazon_aws/0oa3omz2i9XRNSRIHBZO/272", 18 | "logoUrl": "https://organization.okta.com/img/logos/amazon-aws.png", 19 | "appName": "amazon_aws", 20 | "appInstanceId": "0oa3omz2i9XRNSRIHBZO", 21 | "appAssignmentId": "0ua3omz7weMMMQJERBKY", 22 | "credentialsSetup": false, 23 | "hidden": false, 24 | "sortOrder": 0 25 | }, 26 | { 27 | "id": "00ub0oNGTSWTBKOLGLNR", 28 | "label": "Google Apps Calendar", 29 | "linkUrl": "https://organization.okta.com/home/google/0oa3omz2i9XRNSRIHBZO/54", 30 | "logoUrl": "https://organization.okta.com/img/logos/google-calendar.png", 31 | "appName": "google", 32 | "appInstanceId": "0oa3omz2i9XRNSRIHBZO", 33 | "appAssignmentId": "0ua3omz7weMMMQJERBKY", 34 | "credentialsSetup": false, 35 | "hidden": false, 36 | "sortOrder": 1 37 | } 38 | ] -------------------------------------------------------------------------------- /aws_okta_processor/__init__.py: -------------------------------------------------------------------------------- 1 | """Root Module for the AWS Okta Processor package.""" 2 | 3 | import importlib.metadata 4 | import tomlkit 5 | 6 | 7 | def get_version(): 8 | """ 9 | Fetches the current version of the AWS Okta Processor package. 10 | 11 | This function first tries to retrieve the 12 | version from the installed package distribution. 13 | If the package is not installed as a distribution (e.g., during development), 14 | it will instead attempt to load the version directly from the `pyproject.toml` file. 15 | 16 | Returns: 17 | str: The version string of the package. 18 | 19 | Raises: 20 | FileNotFoundError: If `pyproject.toml` is 21 | not found and the package is not installed. 22 | """ 23 | try: 24 | # Attempt to get the version from the installed package distribution 25 | return importlib.metadata.version(__package__) 26 | except importlib.metadata.PackageNotFoundError: 27 | # If distribution not found, load the version from pyproject.toml file 28 | with open("../../pyproject.toml", encoding="utf-8") as pyproject: 29 | file_contents = pyproject.read() 30 | 31 | # Parse the version from the TOML structure in pyproject.toml 32 | return tomlkit.parse(file_contents)["tool"]["poetry"]["version"] 33 | 34 | 35 | # Set the module's __version__ attribute by calling get_version 36 | __version__ = get_version() 37 | -------------------------------------------------------------------------------- /aws_okta_processor/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Usage: 3 | aws-okta-processor [options] [...] 4 | 5 | Options: 6 | -h, --help Show this screen. 7 | --version Show version. 8 | 9 | Commands: 10 | authenticate used to authenticate into AWS using Okta 11 | get-roles used to get AWS roles 12 | 13 | Help: 14 | For help using this tool, visit here for docs and issues: 15 | https://github.com/godaddy/aws-okta-processor 16 | 17 | See 'aws-okta-processor -h' for more information on a specific command. 18 | """ # noqa 19 | 20 | import sys 21 | 22 | from docopt import docopt # type: ignore[import-untyped] 23 | 24 | from . import __version__ as VERSION 25 | 26 | from . import commands 27 | 28 | 29 | def main(): 30 | """Main CLI entrypoint.""" 31 | args = docopt(__doc__, version=VERSION, options_first=True) 32 | 33 | try: 34 | argv = [args[""]] + args[""] 35 | if args[""] == "authenticate": 36 | options = docopt(commands.authenticate.__doc__, argv=argv) 37 | command = commands.authenticate.Authenticate(options) 38 | command.run() 39 | elif args[""] == "get-roles": 40 | options = docopt(commands.getroles.__doc__, argv=argv) 41 | command = commands.getroles.GetRoles(options) 42 | command.run() 43 | else: 44 | sys.exit( 45 | f"{args['']!r} is not an aws-okta-processor " 46 | "command. See 'aws-okta-processor --help'." 47 | ) 48 | except KeyboardInterrupt: 49 | sys.exit(0) 50 | -------------------------------------------------------------------------------- /tests/commands/test_base.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import tests 4 | from tests.test_base import TestBase 5 | 6 | from aws_okta_processor.commands.base import Base 7 | 8 | 9 | class TestBase(TestBase): 10 | def test_get_config(self): 11 | with self.assertRaises(NotImplementedError) as context: 12 | Base(self.OPTIONS) 13 | 14 | @patch('os.path.expanduser', side_effect=tests.expand_user_side_effect) 15 | def test_get_userfile_should_locate_user_file(self, mock_expanduser): 16 | actual = Base.get_userfile() 17 | self.assertEqual(tests.get_fixture('userhome/.awsoktaprocessor'), actual) 18 | 19 | @patch('os.path.exists', side_effect=tests.get_os_exists_side_effect({'.awsoktaprocessor': True})) 20 | def test_get_cwdfile_should_locate_cwd_file(self, mock_os_exists): 21 | actual = Base.get_cwdfile() 22 | self.assertEqual('.awsoktaprocessor', actual) 23 | 24 | @patch('aws_okta_processor.commands.base.Base.get_userfile', 25 | return_value=tests.get_fixture('userhome/.awsoktaprocessor')) 26 | @patch('aws_okta_processor.commands.base.Base.get_cwdfile', 27 | return_value=tests.get_fixture('.awsoktaprocessor')) 28 | def test_extends_configuration_should_extend_user_and_cwd(self, mock_userfile, mock_cwdfile): 29 | authenticate = tests.TestCommand(self.OPTIONS) 30 | 31 | config = authenticate.extend_configuration({ 32 | 'AWS_OKTA_ENVIRONMENT': None, 33 | 'AWS_OKTA_ORGANIZATION': 'org1', 34 | }, 'authenticate', { 35 | 'AWS_OKTA_ENVIRONMENT': 'environment', 36 | 'AWS_OKTA_ORGANIZATION': 'organization' 37 | }) 38 | 39 | self.assertEqual('okta-env1', config['AWS_OKTA_ENVIRONMENT']) 40 | self.assertEqual('org1', config['AWS_OKTA_ORGANIZATION']) -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from unittest import TestCase 4 | 5 | WORKING_DIR = os.path.dirname(__file__) 6 | ABS_PATH = os.path.abspath(WORKING_DIR) 7 | SAML_RESPONSE_PATH = os.path.join(ABS_PATH, "SAML_RESPONSE") 8 | SAML_RESPONSE = open(SAML_RESPONSE_PATH, 'r').read() 9 | SIGN_IN_RESPONSE_PATH = os.path.join(ABS_PATH, "SIGN_IN_RESPONSE") 10 | SIGN_IN_RESPONSE = open(SIGN_IN_RESPONSE_PATH, 'r').read() 11 | SESSION_RESPONSE_PATH = os.path.join(ABS_PATH, "SESSION_RESPONSE") 12 | SESSION_RESPONSE = open(SESSION_RESPONSE_PATH, 'r').read() 13 | AUTH_TOKEN_RESPONSE_PATH = os.path.join(ABS_PATH, "AUTH_TOKEN_RESPONSE") 14 | AUTH_TOKEN_RESPONSE = open(AUTH_TOKEN_RESPONSE_PATH, 'r').read() 15 | AUTH_MFA_PUSH_RESPONSE_PATH = os.path.join(ABS_PATH, "AUTH_MFA_PUSH_RESPONSE") 16 | AUTH_MFA_PUSH_RESPONSE = open(AUTH_MFA_PUSH_RESPONSE_PATH, 'r').read() 17 | AUTH_MFA_TOTP_RESPONSE_PATH = os.path.join(ABS_PATH, "AUTH_MFA_TOTP_RESPONSE") 18 | AUTH_MFA_TOTP_RESPONSE = open(AUTH_MFA_TOTP_RESPONSE_PATH, 'r').read() 19 | AUTH_MFA_YUBICO_HARDWARE_RESPONSE_PATH = os.path.join(ABS_PATH, "AUTH_MFA_YUBICO_HARDWARE_RESPONSE") 20 | AUTH_MFA_YUBICO_HARDWARE_RESPONSE = open(AUTH_MFA_YUBICO_HARDWARE_RESPONSE_PATH, 'r').read() 21 | AUTH_MFA_MULTIPLE_RESPONSE_PATH = os.path.join(ABS_PATH, "AUTH_MFA_MULTIPLE_RESPONSE") 22 | AUTH_MFA_MULTIPLE_RESPONSE = open(AUTH_MFA_MULTIPLE_RESPONSE_PATH, 'r').read() 23 | MFA_WAITING_RESPONSE_PATH = os.path.join(ABS_PATH, "MFA_WAITING_RESPONSE") 24 | MFA_WAITING_RESPONSE = open(MFA_WAITING_RESPONSE_PATH, 'r').read() 25 | APPLICATIONS_RESPONSE_PATH = os.path.join(ABS_PATH, "APPLICATIONS_RESPONSE") 26 | APPLICATIONS_RESPONSE = open(APPLICATIONS_RESPONSE_PATH, 'r').read() 27 | 28 | 29 | class TestBase(TestCase): 30 | def setUp(self): 31 | self.OPTIONS = { 32 | "--environment": False, 33 | "--user": "user_name", 34 | "--pass": None, 35 | "--organization": "org.okta.com", 36 | "--application": None, 37 | "--role": None, 38 | "--region": None, 39 | "--sign-in-url": None, 40 | "--key": "key", 41 | "--duration": "3600", 42 | "--factor": None, 43 | "--silent": False, 44 | "--no-okta-cache": False, 45 | "--no-aws-cache": False 46 | } 47 | -------------------------------------------------------------------------------- /tests/core/test_saml.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from mock import patch 3 | from mock import MagicMock 4 | from tests.test_base import SAML_RESPONSE 5 | from tests.test_base import SIGN_IN_RESPONSE 6 | 7 | from aws_okta_processor.core import saml 8 | 9 | 10 | class TestSAMLUtils(TestCase): 11 | @patch('aws_okta_processor.core.saml.print_tty') 12 | @patch('aws_okta_processor.core.saml.sys') 13 | def test_get_saml_assertion(self, mock_sys, mock_print_tty): 14 | mock_sys.exit.side_effect = SystemExit 15 | saml.get_saml_assertion(saml_response=SAML_RESPONSE) 16 | 17 | with self.assertRaises(SystemExit): 18 | saml.get_saml_assertion(saml_response="") 19 | 20 | mock_print_tty.assert_called_once_with("ERROR: SAMLResponse tag was not found!") # noqa 21 | mock_sys.exit.assert_called_once_with(1) 22 | 23 | @patch('aws_okta_processor.core.saml.requests') 24 | def test_get_account_roles(self, mock_requests): 25 | mock_response = MagicMock() 26 | mock_response.text = SIGN_IN_RESPONSE 27 | mock_requests.post.return_value = mock_response 28 | 29 | account_roles = saml.get_account_roles(saml_assertion="ASSERTION") 30 | 31 | self.assertEqual( 32 | account_roles[0].account_name, 33 | "Account: account-one (1)" 34 | ) 35 | 36 | self.assertEqual(account_roles[0].role_arn, "arn:aws:iam::1:role/Role-One") # noqa 37 | self.assertEqual(account_roles[1].account_name, "Account: account-one (1)") # noqa 38 | self.assertEqual(account_roles[1].role_arn, "arn:aws:iam::1:role/Role-Two") # noqa 39 | self.assertEqual(account_roles[2].account_name, "Account: account-two (2)") # noqa 40 | self.assertEqual(account_roles[2].role_arn, "arn:aws:iam::2:role/Role-One") # noqa 41 | 42 | @patch('aws_okta_processor.core.saml.requests') 43 | def test_get_aws_roles(self, mock_requests): 44 | mock_response = MagicMock() 45 | mock_response.text = SIGN_IN_RESPONSE 46 | mock_requests.post.return_value = mock_response 47 | 48 | saml_assertion = saml.get_saml_assertion(saml_response=SAML_RESPONSE) 49 | aws_roles = saml.get_aws_roles(saml_assertion=saml_assertion) 50 | 51 | self.assertIn("Account: account-one (1)", aws_roles) 52 | self.assertIn("arn:aws:iam::1:role/Role-One", aws_roles["Account: account-one (1)"]) # noqa 53 | self.assertIn("arn:aws:iam::1:role/Role-Two", aws_roles["Account: account-one (1)"]) # noqa 54 | self.assertIn("Account: account-two (2)", aws_roles) 55 | self.assertIn("arn:aws:iam::2:role/Role-One", aws_roles["Account: account-two (2)"]) # noqa 56 | -------------------------------------------------------------------------------- /tests/SIGN_IN_RESPONSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Amazon Web Services Sign-In 5 | 6 | 7 | 8 | 9 | 10 |
11 |

Amazon Web Services Login

12 |
13 |
14 |
15 | 16 | 17 | 18 | 19 |

Select a role:

20 |
21 | 50 |
51 | 52 |
53 |
54 | Sign In 55 |
56 |
57 |
58 |
59 | 60 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at oss@godaddy.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /aws_okta_processor/commands/base.py: -------------------------------------------------------------------------------- 1 | """The base command module. 2 | 3 | This module defines the `Base` class, which serves as a foundation for command classes. 4 | It provides common functionalities such as configuration handling and file management. 5 | """ 6 | 7 | import configparser 8 | import os 9 | 10 | DOTFILE = ".awsoktaprocessor" # Dotfile name in the current directory 11 | USER_DOTFILE = "~/" + DOTFILE # Dotfile path in the user's home directory 12 | 13 | 14 | class Base: 15 | """A base command class. 16 | 17 | This class provides the structure and common methods for command classes. 18 | Subclasses should implement the `run` and `get_configuration` methods. 19 | """ 20 | 21 | def __init__(self, options, *args, **kwargs): 22 | """Initialize the Base command. 23 | 24 | Args: 25 | options (dict): A dictionary of command options. 26 | *args: Variable length argument list. 27 | **kwargs: Arbitrary keyword arguments. 28 | """ 29 | self.args = args 30 | self.kwargs = kwargs 31 | 32 | self.configuration = self.get_configuration(options=options) 33 | 34 | def run(self): 35 | """Execute the command. 36 | 37 | Raises: 38 | NotImplementedError: Indicates that the method should be implemented by subclasses. 39 | """ # noqa: E501 40 | raise NotImplementedError("You must implement the run() method yourself!") 41 | 42 | def get_configuration(self, options=None): 43 | """Retrieve the configuration for the command. 44 | 45 | Args: 46 | options (dict, optional): A dictionary of options to be used for configuration. 47 | 48 | Raises: 49 | NotImplementedError: Indicates that the method should be implemented by subclasses. 50 | """ # noqa: E501 51 | raise NotImplementedError( 52 | "You must implement the get_configuration() method yourself!" 53 | ) 54 | 55 | @staticmethod 56 | def get_userfile(): 57 | """Get the path to the user's dotfile if it exists. 58 | 59 | Expands the user's home directory and checks if the dotfile exists. 60 | 61 | Returns: 62 | str or None: The full path to the user's dotfile, or None if it doesn't exist. 63 | """ # noqa: E501 64 | user_file = os.path.expanduser(USER_DOTFILE) 65 | if os.path.exists(user_file): 66 | return user_file 67 | return None 68 | 69 | @staticmethod 70 | def get_cwdfile(): 71 | """Get the path to the dotfile in the current working directory if it exists. 72 | 73 | Returns: 74 | str or None: The filename of the dotfile, or None if it doesn't exist. 75 | """ 76 | if os.path.exists(DOTFILE): 77 | return DOTFILE 78 | return None 79 | 80 | def extend_configuration(self, configuration, command, mapping): 81 | """Extend the given configuration with options from dotfiles. 82 | 83 | Reads configuration options from dotfiles in the user's home directory 84 | and the current directory, and updates the given configuration dictionary accordingly. 85 | 86 | Args: 87 | configuration (dict): The original configuration dictionary. 88 | command (str): The name of the command section to read from the config files. 89 | mapping (dict): A mapping from configuration keys to option names in the config files. 90 | 91 | Returns: 92 | dict: The updated configuration dictionary. 93 | """ # noqa: E501 94 | files = [] 95 | user_file = Base.get_userfile() 96 | if user_file is not None: 97 | files.append(user_file) 98 | 99 | cwd_file = Base.get_cwdfile() 100 | if cwd_file is not None: 101 | files.append(cwd_file) 102 | 103 | if files: 104 | config = configparser.ConfigParser() 105 | config.read(files) 106 | 107 | options = {} 108 | if config.has_section("defaults"): 109 | options = {**config["defaults"]} 110 | 111 | if config.has_section(command): 112 | options = dict(options, **config[command]) 113 | 114 | for k, v in configuration.items(): 115 | if v is None: 116 | option_name = mapping[k] 117 | configuration[k] = options.get(option_name, None) 118 | 119 | return configuration 120 | -------------------------------------------------------------------------------- /tests/commands/test_getroles.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from unittest.mock import patch 4 | 5 | from tests.test_base import TestBase 6 | 7 | from aws_okta_processor.commands.getroles import GetRoles 8 | 9 | 10 | class TestGetRolesCommand(TestBase): 11 | @patch("aws_okta_processor.commands.getroles.SAMLFetcher.get_app_roles") 12 | def test_get_accounts_and_roles_should_return_valid_results(self, mock_get_app_roles): 13 | self.OPTIONS["--output"] = "json" 14 | mock_get_app_roles.return_value = { 15 | "Application": "app-url", 16 | "User": "jdoe", 17 | "Organization": "test-org", 18 | "Accounts": { 19 | "Account: test-account (1234)": [ 20 | "role1-deploy" 21 | ] 22 | } 23 | } 24 | command = GetRoles(self.OPTIONS) 25 | actual = command.get_accounts_and_roles() 26 | 27 | self.assertEqual({ 28 | 'accounts': [ 29 | { 30 | 'id': '1234', 31 | 'name': 'test-account', 32 | 'name_raw': 'Account: test-account (1234)', 33 | 'roles': [ 34 | { 35 | 'name': 'role1-deploy', 'suffix': 'deploy' 36 | } 37 | ] 38 | } 39 | ], 40 | 'application_url': 'app-url', 41 | 'organization': 'test-org', 42 | 'user': 'jdoe' 43 | }, actual) 44 | 45 | @patch("aws_okta_processor.commands.getroles.GetRoles.get_accounts_and_roles") 46 | @patch("aws_okta_processor.commands.getroles.sys.stdout.write") 47 | def test_run_should_return_json(self, mock_sys_stdout_write, mock_get_accounts_and_roles): 48 | self.OPTIONS["--output"] = "json" 49 | mock_get_accounts_and_roles.return_value = { 50 | "application_url": "app-url", 51 | "accounts": [ 52 | { 53 | "name": "test-account", 54 | "name_raw": "test-account-raw", 55 | "id": "1234", 56 | "roles": [ 57 | { 58 | "name": "role1-deploy", 59 | "suffix": "deploy" 60 | } 61 | ] 62 | } 63 | ], 64 | "user": "jdoe", 65 | "organization": "test-org" 66 | } 67 | command = GetRoles(self.OPTIONS) 68 | command.run() 69 | mock_sys_stdout_write.assert_called_once_with( 70 | '{"application_url": "app-url", "accounts": [{"name": "test-account", "name_raw": "test-account-raw", ' 71 | '"id": "1234", "roles": [{"name": "role1-deploy", "suffix": "deploy"}]}], "user": "jdoe", ' 72 | '"organization": "test-org"}' 73 | ) 74 | 75 | @patch("aws_okta_processor.commands.getroles.GetRoles.get_accounts_and_roles") 76 | @patch("aws_okta_processor.commands.getroles.sys.stdout.write") 77 | def test_run_should_return_profiles(self, mock_sys_stdout_write, mock_get_accounts_and_roles): 78 | self.OPTIONS["--output"] = "profiles" 79 | mock_get_accounts_and_roles.return_value = { 80 | "application_url": "app-url", 81 | "accounts": [ 82 | { 83 | "name": "test-account", 84 | "name_raw": "test-account-raw", 85 | "id": "1234", 86 | "roles": [ 87 | { 88 | "name": "role1-deploy", 89 | "suffix": "deploy" 90 | } 91 | ] 92 | } 93 | ], 94 | "user": "jdoe", 95 | "organization": "test-org" 96 | } 97 | command = GetRoles(self.OPTIONS) 98 | command.run() 99 | mock_sys_stdout_write.assert_called_once_with( 100 | '\n[test-account-deploy]\ncredential_process=aws-okta-processor authenticate --organization="test-org"' 101 | ' --user="jdoe" --application="app-url" --role="role1-deploy" --key="test-account-role1-deploy"\n' 102 | ) 103 | 104 | @patch("aws_okta_processor.commands.getroles.GetRoles.get_accounts_and_roles") 105 | @patch("aws_okta_processor.commands.getroles.sys.stdout.write") 106 | def test_run_should_return_text(self, mock_sys_stdout_write, mock_get_accounts_and_roles): 107 | self.OPTIONS["--output"] = "text" 108 | os.environ["AWS_OKTA_OUTPUT_FORMAT"] = "{account},{role}" 109 | 110 | mock_get_accounts_and_roles.return_value = { 111 | "application_url": "app-url", 112 | "accounts": [ 113 | { 114 | "name": "test-account", 115 | "name_raw": "test-account-raw", 116 | "id": "1234", 117 | "roles": [ 118 | { 119 | "name": "role1-deploy", 120 | "suffix": "deploy" 121 | } 122 | ] 123 | } 124 | ], 125 | "user": "jdoe", 126 | "organization": "test-org" 127 | } 128 | command = GetRoles(self.OPTIONS) 129 | command.run() 130 | mock_sys_stdout_write.assert_called_once_with( 131 | 'test-account,role1-deploy\n' 132 | ) 133 | 134 | def test_get_pass_config(self): 135 | self.OPTIONS["--pass"] = "user_pass_two" 136 | authenticate = GetRoles(self.OPTIONS) 137 | assert authenticate.get_pass() == "user_pass_two" 138 | 139 | def test_get_key_dict(self): 140 | authenticate = GetRoles(self.OPTIONS) 141 | key_dict = authenticate.get_key_dict() 142 | 143 | self.assertEqual( 144 | key_dict, 145 | { 146 | "Organization": self.OPTIONS["--organization"], 147 | "User": self.OPTIONS["--user"], 148 | "Key": self.OPTIONS["--key"], 149 | } 150 | ) 151 | -------------------------------------------------------------------------------- /tests/commands/test_authenticate.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import tests 4 | import os 5 | 6 | from aws_okta_processor.commands.authenticate import Authenticate 7 | from tests.test_base import TestBase 8 | 9 | 10 | CREDENTIALS = { 11 | "AccessKeyId": "access_key_id", 12 | "SecretAccessKey": "secret_access_key", 13 | "SessionToken": "session_token", 14 | "Expiration": "expiration" 15 | } 16 | 17 | 18 | class TestAuthenticate(TestBase): 19 | @patch("aws_okta_processor.commands.authenticate.JSONFileCache") 20 | @patch("aws_okta_processor.commands.authenticate.SAMLFetcher") 21 | def test_authenticate(self, mock_saml_fetcher, mock_json_file_cache): 22 | mock_saml_fetcher().fetch_credentials.return_value = CREDENTIALS 23 | auth = Authenticate(self.OPTIONS) 24 | credentials = auth.authenticate() 25 | 26 | mock_json_file_cache.assert_called_once_with() 27 | assert credentials == CREDENTIALS 28 | 29 | @patch("aws_okta_processor.commands.authenticate.print") 30 | def test_run(self, mock_print): 31 | auth = Authenticate(self.OPTIONS) 32 | auth.authenticate = (lambda: CREDENTIALS) 33 | auth.run() 34 | 35 | mock_print.assert_called_once_with( 36 | '{"AccessKeyId": "access_key_id", ' 37 | '"SecretAccessKey": "secret_access_key", ' 38 | '"SessionToken": "session_token", ' 39 | '"Expiration": "expiration", ' 40 | '"Version": 1}' 41 | ) 42 | 43 | @patch("aws_okta_processor.commands.authenticate.os") 44 | @patch("aws_okta_processor.commands.authenticate.print") 45 | def test_run_nt(self, mock_print, mock_os): 46 | mock_os.name = "nt" 47 | self.OPTIONS["--environment"] = True 48 | auth = Authenticate(self.OPTIONS) 49 | auth.authenticate = (lambda: CREDENTIALS) 50 | auth.run() 51 | 52 | mock_print.assert_called_once_with( 53 | "$env:AWS_ACCESS_KEY_ID='access_key_id'; " 54 | "$env:AWS_SECRET_ACCESS_KEY='secret_access_key'; " 55 | "$env:AWS_SESSION_TOKEN='session_token'; " 56 | "$env:AWS_CREDENTIAL_EXPIRATION='expiration'" 57 | ) 58 | 59 | @patch("aws_okta_processor.commands.authenticate.os") 60 | @patch("aws_okta_processor.commands.authenticate.print") 61 | def test_run_linux(self, mock_print, mock_os): 62 | mock_os.name = "linux" 63 | self.OPTIONS["--environment"] = True 64 | auth = Authenticate(self.OPTIONS) 65 | auth.authenticate = (lambda: CREDENTIALS) 66 | auth.run() 67 | 68 | mock_print.assert_called_once_with( 69 | "export AWS_ACCESS_KEY_ID='access_key_id' && " 70 | "export AWS_SECRET_ACCESS_KEY='secret_access_key' && " 71 | "export AWS_SESSION_TOKEN='session_token' && " 72 | "export AWS_CREDENTIAL_EXPIRATION='expiration'" 73 | ) 74 | 75 | def test_get_configuration_env(self): 76 | os.environ["AWS_OKTA_ENVIRONMENT"] = "1" 77 | auth = Authenticate(self.OPTIONS) 78 | del os.environ["AWS_OKTA_ENVIRONMENT"] 79 | 80 | assert auth.configuration["AWS_OKTA_ENVIRONMENT"] == "1" 81 | 82 | def test_output_export_command_with_fish_as_target_shell(self): 83 | """ Tests the export command for fish shell """ 84 | 85 | self.OPTIONS["--target-shell"] = "fish" 86 | auth = Authenticate(self.OPTIONS) 87 | credentials = { 88 | "AccessKeyId": "WWWWW", 89 | "SecretAccessKey": "XXXXX", 90 | "SessionToken": "YYYYY", 91 | "Expiration": "ZZZZZ" 92 | } 93 | self.assertNotIsInstance( 94 | auth.unix_output(credentials).index("set --export"), 95 | ValueError 96 | ) 97 | 98 | def test_output_export_command_with_default_target_shell(self): 99 | """ Tests the export command for bash (default target shell) """ 100 | 101 | auth = Authenticate(self.OPTIONS) 102 | credentials = { 103 | "AccessKeyId": "WWWWW", 104 | "SecretAccessKey": "XXXXX", 105 | "SessionToken": "YYYYY", 106 | "Expiration": "ZZZZZ" 107 | } 108 | self.assertNotIsInstance( 109 | auth.unix_output(credentials).index("export "), 110 | ValueError 111 | ) 112 | self.assertNotIsInstance( 113 | auth.unix_output(credentials).index(" && "), 114 | ValueError 115 | ) 116 | 117 | def test_output_export_command_for_windows(self): 118 | """ Tests the export command for windows operating system """ 119 | 120 | auth = Authenticate(self.OPTIONS) 121 | credentials = { 122 | "AccessKeyId": "WWWWW", 123 | "SecretAccessKey": "XXXXX", 124 | "SessionToken": "YYYYY", 125 | "Expiration": "ZZZZZ" 126 | } 127 | self.assertNotIsInstance( 128 | auth.nt_output(credentials).index("$env:"), 129 | ValueError 130 | ) 131 | 132 | def test_get_pass_config(self): 133 | self.OPTIONS["--pass"] = "user_pass_two" 134 | authenticate = Authenticate(self.OPTIONS) 135 | assert authenticate.get_pass() == "user_pass_two" 136 | 137 | def test_get_key_dict(self): 138 | authenticate = Authenticate(self.OPTIONS) 139 | key_dict = authenticate.get_key_dict() 140 | 141 | self.assertEqual( 142 | key_dict, 143 | { 144 | "Organization": self.OPTIONS["--organization"], 145 | "User": self.OPTIONS["--user"], 146 | "Key": self.OPTIONS["--key"], 147 | } 148 | ) 149 | 150 | 151 | @patch('aws_okta_processor.commands.base.Base.get_userfile', 152 | return_value=tests.get_fixture('userhome/.awsoktaprocessor')) 153 | @patch('aws_okta_processor.commands.base.Base.get_cwdfile', 154 | return_value=tests.get_fixture('.awsoktaprocessor')) 155 | def test_extends_by_file(self, mock_userfile, mock_cwdfile): 156 | authenticate = Authenticate(self.OPTIONS) 157 | 158 | config = authenticate.get_configuration(options={}) 159 | 160 | self.assertEqual('okta-env1', config['AWS_OKTA_ENVIRONMENT']) 161 | self.assertEqual('okta-user1', config['AWS_OKTA_USER']) 162 | self.assertEqual('okta-pass1', config['AWS_OKTA_PASS']) 163 | self.assertEqual('org1-from-home', config['AWS_OKTA_ORGANIZATION']) 164 | -------------------------------------------------------------------------------- /aws_okta_processor/core/prompt.py: -------------------------------------------------------------------------------- 1 | """Module for prompting the user for input.""" 2 | 3 | import sys 4 | from collections.abc import Mapping 5 | 6 | import six # type: ignore[import-untyped] 7 | from aws_okta_processor.core.tty import print_tty, input_tty 8 | 9 | 10 | def get_item(items=None, label=None, key=None): 11 | """ 12 | Retrieves a specific item or prompts the user to select one from multiple options. 13 | 14 | Args: 15 | items (dict): The collection of items to search within. 16 | label (str): Descriptive label of the items (e.g., "Account", "Role"). 17 | key (str, optional): Specific key to look up in items. 18 | 19 | Returns: 20 | object: The value associated with the provided key, or the user-selected item. 21 | 22 | Exits: 23 | If items is empty or the key is not found, exits with an error message. 24 | """ 25 | if not items: 26 | # Display error if items are empty and exit 27 | print_tty(f"ERROR: No {label}s were found!") 28 | sys.exit(1) 29 | 30 | if key: 31 | # Attempt to get the specific item value for the given key 32 | item_value = get_deep_value(items=items, key=key) 33 | if item_value: 34 | return item_value 35 | 36 | # If the key does not exist, display error and exit 37 | print_tty(f"ERROR: {label} {key} not found!") 38 | sys.exit(1) 39 | 40 | # If there are multiple items, prompt user to select one 41 | if get_deep_length(items=items) > 1: 42 | print_tty(f"Select {label}:") 43 | options = get_options(items=items) 44 | return get_selection(options=options) 45 | 46 | # Return single item value if only one exists 47 | return get_deep_value(items=items) 48 | 49 | 50 | def get_deep_length(items=None, length=0): 51 | """ 52 | Recursively calculates the number of items in a nested dictionary. 53 | 54 | Args: 55 | items (dict): Dictionary with nested dictionaries. 56 | length (int): Running count of items found. 57 | 58 | Returns: 59 | int: Total count of non-dictionary items. 60 | """ 61 | for item_value in items.values(): 62 | if isinstance(item_value, Mapping): 63 | # Recursively count items in nested dictionaries 64 | length += get_deep_length(items=item_value) 65 | else: 66 | # Increment length for each non-dictionary item 67 | length += 1 68 | 69 | return length 70 | 71 | 72 | def get_deep_value(items=None, key=None, results=None): 73 | """ 74 | Recursively searches for a specific key's value in a nested dictionary. 75 | 76 | Args: 77 | items (dict): Dictionary with nested dictionaries. 78 | key (str, optional): Key to search for. 79 | results (list, optional): Accumulated results from recursive calls. 80 | 81 | Returns: 82 | object: The first found value associated with the key, or None if not found. 83 | """ 84 | if results is None: 85 | results = [] 86 | else: 87 | if not key and results: 88 | # Return early if results are already accumulated and no key specified 89 | return results 90 | 91 | for item_key, item_value in six.iteritems(items): 92 | if isinstance(item_value, Mapping): 93 | # Recursively search in nested dictionaries 94 | get_deep_value(items=item_value, key=key, results=results) 95 | elif key: 96 | if item_key == key: 97 | # Append item to results if key matches 98 | results.append(item_value) 99 | else: 100 | # Append item to results if no specific key is provided 101 | results.append(item_value) 102 | 103 | if results: 104 | # Return the first found result 105 | return results[0] 106 | 107 | return None 108 | 109 | 110 | def get_selection(options=None): 111 | """ 112 | Prompts the user to select an option from a list. 113 | 114 | Args: 115 | options (list): List of available options to choose from. 116 | 117 | Returns: 118 | object: The selected option based on user input. 119 | 120 | Exits: 121 | If user interrupts the input process with Ctrl+C. 122 | """ 123 | print_tty("Selection: ", newline=False) 124 | 125 | try: 126 | # Wait for user input 127 | selection = input_tty() 128 | except KeyboardInterrupt: 129 | # Handle user interrupt gracefully 130 | print_tty() 131 | sys.exit(1) 132 | except SyntaxError: 133 | # Handle syntax error and re-prompt the user 134 | print_tty(f"WARNING: Please supply a value from 1 to {len(options)}!") 135 | return get_selection(options=options) 136 | 137 | try: 138 | # Convert selection to an integer and validate range 139 | selection = int(selection) 140 | if 0 < selection <= len(options): 141 | return options[selection - 1] 142 | 143 | # If out of range, display error and re-prompt 144 | print_tty(f"WARNING: Please supply a value from 1 to {len(options)}!") 145 | return get_selection(options=options) 146 | 147 | except ValueError: 148 | # Handle non-integer input and re-prompt 149 | print_tty(f"WARNING: Please supply a value from 1 to {len(options)}!") 150 | return get_selection(options=options) 151 | 152 | 153 | def get_options(items=None, options=None, depth=0): 154 | """ 155 | Displays options from a nested dictionary and accumulates them for selection. 156 | 157 | Args: 158 | items (dict): Dictionary with items to display. 159 | options (list, optional): List to accumulate options for selection. 160 | depth (int): Current depth of recursion for indentation. 161 | 162 | Returns: 163 | list: List of accumulated options for user selection. 164 | """ 165 | if options is None: 166 | options = [] 167 | 168 | for key, value in six.iteritems(items): 169 | if isinstance(value, Mapping): 170 | # Print category label at current indentation level 171 | print_tty(key, indents=depth) 172 | # Recursively add sub-options 173 | options = get_options(items=value, options=options, depth=depth + 1) 174 | else: 175 | # For specific values, adjust key display if it contains an ARN 176 | key = key.split("/")[-1] 177 | # Print option with an index 178 | print_tty(f"[ {len(options) + 1} ] {key}", indents=depth) 179 | options.append(value) 180 | 181 | return options 182 | -------------------------------------------------------------------------------- /tests/core/test_prompt.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from mock import patch 3 | from mock import call 4 | from collections import OrderedDict 5 | 6 | import aws_okta_processor.core.prompt as prompt 7 | 8 | 9 | ITEMS = OrderedDict() 10 | ITEMS["ItemOne"] = "ValueOne" 11 | ITEMS["ItemTwo"] = {"ItemTwoNestOne": "ValueTwo"} 12 | ITEMS["ItemThree"] = "ValueThree" 13 | ITEMS["ItemFour"] = { 14 | "ItemFourNestOne": { 15 | "ItemFourNestTwo": "ValueFour" 16 | } 17 | } 18 | 19 | 20 | class TestPrompt(TestCase): 21 | def test_get_item(self): 22 | items = {"ItemOne": "ValueOne"} 23 | item_value = prompt.get_item(items=items, label="ItemOne") 24 | self.assertEqual(item_value, "ValueOne") 25 | 26 | @patch('aws_okta_processor.core.prompt.print_tty') 27 | @patch('aws_okta_processor.core.prompt.sys') 28 | def test_get_item_no_items(self, mock_sys, mock_print_tty): 29 | mock_sys.exit.side_effect = SystemExit 30 | with self.assertRaises(SystemExit): 31 | prompt.get_item(items={}, label="Item") 32 | 33 | mock_sys.exit.assert_called_once_with(1) 34 | mock_print_tty.assert_called_with( 35 | "ERROR: No Items were found!" 36 | ) 37 | 38 | @patch('aws_okta_processor.core.prompt.print_tty') 39 | @patch('aws_okta_processor.core.prompt.get_options') 40 | @patch('aws_okta_processor.core.prompt.get_selection') 41 | def test_get_item_select(self, mock_get_selection, mock_get_options, mock_print_tty): # noqa 42 | options = ["ValueOne", "ValueTwo"] 43 | mock_get_options.return_value = options 44 | prompt.get_item(items=ITEMS, label="Item") 45 | mock_print_tty.assert_called_once_with("Select Item:") 46 | mock_get_options.assert_called_once_with(items=ITEMS) 47 | mock_get_selection.assert_called_once_with(options=options) 48 | 49 | def test_get_item_config(self): 50 | items = {"item_one": "value_one", "item_two": "value_two"} 51 | item_value = prompt.get_item(items=items, label="Item", key="item_two") # noqa 52 | self.assertEqual(item_value, "value_two") 53 | 54 | @patch('aws_okta_processor.core.prompt.print_tty') 55 | @patch('aws_okta_processor.core.prompt.sys') 56 | def test_get_item_config_no_match(self, mock_sys, mock_print_tty): 57 | items = {"item_one": "value_one", "item_two": "value_two"} 58 | mock_sys.exit.side_effect = SystemExit 59 | with self.assertRaises(SystemExit): 60 | prompt.get_item(items=items, label="Item", key="item_three") 61 | 62 | mock_sys.exit.assert_called_once_with(1) 63 | mock_print_tty.assert_any_call( 64 | "ERROR: Item item_three not found!" 65 | ) 66 | 67 | @patch('aws_okta_processor.core.prompt.print_tty') 68 | @patch('aws_okta_processor.core.prompt.input_tty') 69 | def test_get_selection(self, mock_input, mock_print_tty): 70 | mock_input.return_value = 1 71 | options = ["one", "two"] 72 | item_value = prompt.get_selection(options=options) 73 | 74 | self.assertEqual(item_value, "one") 75 | mock_print_tty.assert_any_call( 76 | "Selection: ", newline=False 77 | ) 78 | 79 | @patch('aws_okta_processor.core.prompt.print_tty') 80 | @patch('aws_okta_processor.core.prompt.input_tty') 81 | def test_get_selection_bad_input(self, mock_input, mock_print_tty): 82 | mock_input.side_effect = ["bad_input", 2] 83 | options = ["one", "two"] 84 | item_value = prompt.get_selection(options=options) 85 | 86 | self.assertEqual(item_value, "two") 87 | mock_print_tty.assert_any_call( 88 | "WARNING: Please supply a value from 1 to 2!" 89 | ) 90 | 91 | @patch('aws_okta_processor.core.prompt.print_tty') 92 | @patch('aws_okta_processor.core.prompt.input_tty') 93 | def test_get_selection_bad_int(self, mock_input, mock_print_tty): 94 | mock_input.side_effect = [0, 2] 95 | options = ["one", "two"] 96 | item_value = prompt.get_selection(options=options) 97 | 98 | self.assertEqual(item_value, "two") 99 | mock_print_tty.assert_any_call( 100 | "WARNING: Please supply a value from 1 to 2!" 101 | ) 102 | 103 | @patch('aws_okta_processor.core.prompt.print_tty') 104 | @patch('aws_okta_processor.core.prompt.input_tty') 105 | def test_get_selection_no_input(self, mock_input, mock_print_tty): 106 | mock_input.side_effect = [SyntaxError, 2] 107 | options = ["one", "two"] 108 | item_value = prompt.get_selection(options=options) 109 | 110 | self.assertEqual(item_value, "two") 111 | mock_print_tty.assert_any_call( 112 | "WARNING: Please supply a value from 1 to 2!" 113 | ) 114 | 115 | @patch('aws_okta_processor.core.prompt.print_tty') 116 | @patch('aws_okta_processor.core.prompt.sys') 117 | @patch('aws_okta_processor.core.prompt.input_tty') 118 | def test_get_selection_interrupt(self, mock_input, mock_sys, mock_print_tty): # noqa 119 | mock_input.side_effect = [KeyboardInterrupt, 2] 120 | options = ["one"] 121 | 122 | mock_sys.exit.side_effect = SystemExit 123 | with self.assertRaises(SystemExit): 124 | prompt.get_selection(options=options) 125 | 126 | mock_print_tty.assert_any_call( 127 | "Selection: ", newline=False 128 | ) 129 | 130 | @patch('aws_okta_processor.core.prompt.print_tty') 131 | def test_get_options(self, mock_print_tty): 132 | print_tty_calls = [ 133 | call("[ 1 ] ItemOne", indents=0), 134 | call("ItemTwo", indents=0), 135 | call("[ 2 ] ItemTwoNestOne", indents=1), 136 | call("[ 3 ] ItemThree", indents=0), 137 | call("ItemFour", indents=0), 138 | call("ItemFourNestOne", indents=1), 139 | call("[ 4 ] ItemFourNestTwo", indents=2) 140 | ] 141 | 142 | options = prompt.get_options(items=ITEMS) 143 | 144 | self.assertEqual( 145 | options, 146 | ["ValueOne", "ValueTwo", "ValueThree", "ValueFour"] 147 | ) 148 | 149 | mock_print_tty.assert_has_calls(print_tty_calls) 150 | 151 | def test_get_deep_value(self): 152 | value = prompt.get_deep_value(items=ITEMS) 153 | self.assertEqual(value, "ValueOne") 154 | 155 | value = prompt.get_deep_value(items=ITEMS, key="ItemFourNestTwo") 156 | self.assertEqual(value, "ValueFour") 157 | 158 | value = prompt.get_deep_value(items=ITEMS, key="DoesNotExist") 159 | self.assertIs(value, None) 160 | 161 | def test_get_deep_length(self): 162 | length = prompt.get_deep_length(items=ITEMS) 163 | self.assertEqual(length, 4) 164 | -------------------------------------------------------------------------------- /aws_okta_processor/core/tty.py: -------------------------------------------------------------------------------- 1 | """Module for interacting with the terminal.""" 2 | 3 | from __future__ import unicode_literals 4 | 5 | import io 6 | import os 7 | import sys 8 | 9 | from six.moves import range # type: ignore[import-untyped] 10 | 11 | import contextlib2 12 | 13 | 14 | def import_msvcrt(): 15 | """ 16 | Imports the msvcrt module, which is specific to Windows systems. 17 | 18 | Returns: 19 | module: The msvcrt module. 20 | """ 21 | import msvcrt # pylint: disable=C0415,E0401 22 | 23 | return msvcrt 24 | 25 | 26 | def input_tty(): 27 | """ 28 | Handles user input from the terminal, adapting for Windows or Unix systems. 29 | 30 | Returns: 31 | str: The input string entered by the user. 32 | """ 33 | try: 34 | msvcrt = import_msvcrt() 35 | except ImportError: 36 | # If msvcrt is not available (non-Windows), use Unix-based input method 37 | return unix_input_tty() 38 | 39 | # Use Windows-specific input method if msvcrt is available 40 | return win_input_tty(msvcrt) 41 | 42 | 43 | def unix_input_tty(): 44 | """ 45 | Handles user input in Unix systems, using the tty for input if available. 46 | 47 | Returns: 48 | str: The line of input entered by the user. 49 | """ 50 | with contextlib2.ExitStack() as stack: 51 | try: 52 | # Open /dev/tty for reading and writing if available 53 | fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) # pylint: disable=E1101 54 | tty = io.FileIO(fd, "r+") 55 | stack.enter_context(tty) 56 | input = io.TextIOWrapper(tty) # pylint: disable=W0622 57 | stack.enter_context(input) 58 | except OSError: 59 | # Fallback to standard input if /dev/tty is not available 60 | stack.close() 61 | input = sys.stdin # pylint: disable=W0622 62 | 63 | line = input.readline() 64 | # Remove trailing newline if present 65 | if line[-1] == "\n": 66 | line = line[:-1] 67 | return line 68 | 69 | 70 | def win_input_tty(msvcrt): 71 | """ 72 | Handles user input in Windows systems using msvcrt. 73 | 74 | Args: 75 | msvcrt (module): The Windows msvcrt module. 76 | 77 | Returns: 78 | str: The input string entered by the user. 79 | """ 80 | pw = "" 81 | while 1: 82 | c = msvcrt.getwch() # Get a character from the console 83 | if c in ["\r", "\n"]: 84 | # Break on Enter key 85 | break 86 | if c == "\003": 87 | # Handle Ctrl+C as KeyboardInterrupt 88 | raise KeyboardInterrupt 89 | if c == "\b": 90 | # Handle backspace 91 | pw = pw[:-1] 92 | else: 93 | pw = pw + c 94 | 95 | return pw 96 | 97 | 98 | def unix_print_tty(string="", indents=0, newline=True): 99 | """ 100 | Prints a string to the terminal in Unix systems, with optional indentation. 101 | 102 | Args: 103 | string (str): The string to print. 104 | indents (int): Number of indentations to add. 105 | newline (bool): Whether to add a newline after printing. 106 | """ 107 | with contextlib2.ExitStack() as stack: 108 | # Apply indentation 109 | string = indent(indents) + string 110 | fd = None 111 | 112 | try: 113 | # Try printing directly to /dev/tty 114 | fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) # pylint: disable=E1101 115 | tty = io.FileIO(fd, "w+") 116 | stack.enter_context(tty) 117 | text_input = io.TextIOWrapper(tty) 118 | stack.enter_context(text_input) 119 | stream = text_input 120 | except OSError: 121 | # Fallback to sys.stdout if /dev/tty is not available 122 | sys.stdout.write(string) 123 | if newline: 124 | sys.stdout.write("\n") 125 | stack.close() 126 | 127 | # Print to tty stream if available 128 | if fd is not None: 129 | try: 130 | stream.write(string) 131 | if newline: 132 | stream.write("\n") 133 | finally: 134 | # Ensure buffer flushes 135 | stream.flush() 136 | 137 | 138 | def win_print_tty(string="", indents=0, newline=True, msvcrt=None): 139 | """ 140 | Prints a string to the terminal in Windows systems, with optional indentation. 141 | 142 | Args: 143 | string (str): The string to print. 144 | indents (int): Number of indentations to add. 145 | newline (bool): Whether to add a newline after printing. 146 | msvcrt (module): The Windows msvcrt module. 147 | """ 148 | # Apply indentation 149 | string = str(indent(indents) + string) 150 | for c in string: 151 | try: 152 | # Print each character individually 153 | msvcrt.putch(bytes(c.encode())) 154 | except TypeError: 155 | # Fallback if encoding fails 156 | msvcrt.putchc(c) 157 | 158 | # Print newline if specified 159 | if newline: 160 | try: 161 | msvcrt.putch(bytes("\r".encode())) 162 | msvcrt.putch(bytes("\n".encode())) 163 | except TypeError: 164 | msvcrt.putch("\r") 165 | msvcrt.putch("\n") 166 | 167 | 168 | def indent(indents=None): 169 | """ 170 | Generates a string of spaces for indentation. 171 | 172 | Args: 173 | indents (int): Number of indentation levels (each level is two spaces). 174 | 175 | Returns: 176 | str: The indentation string. 177 | """ 178 | indent = "" # pylint: disable=W0621 179 | for _ in range(indents): 180 | indent += " " 181 | return indent 182 | 183 | 184 | def print_tty(string="", indents=0, newline=True, silent=False): 185 | """ 186 | Prints a string to the terminal, selecting Unix or Windows method as needed. 187 | 188 | Args: 189 | string (str): The string to print. 190 | indents (int): Number of indentations to add. 191 | newline (bool): Whether to add a newline after printing. 192 | silent (bool): If True, suppresses printing. 193 | """ 194 | try: 195 | msvcrt = import_msvcrt() 196 | except ImportError: 197 | # If not silent and not on Windows, use Unix print method 198 | if not silent: 199 | unix_print_tty(string=string, indents=indents, newline=newline) 200 | else: 201 | # If not silent and on Windows, use Windows print method 202 | if not silent: 203 | win_print_tty( 204 | string=string, indents=indents, newline=newline, msvcrt=msvcrt 205 | ) 206 | -------------------------------------------------------------------------------- /tests/SAML_RESPONSE: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aws_okta_processor/core/saml.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides functionality to extract SAML assertions from Okta responses 3 | and parse AWS roles from SAML assertions for use in AWS authentication. 4 | """ 5 | 6 | import base64 7 | from collections import OrderedDict 8 | from fnmatch import fnmatch 9 | import sys 10 | 11 | from defusedxml import ElementTree # type: ignore[import-untyped] 12 | from bs4 import BeautifulSoup # type: ignore[import-untyped] 13 | import requests # type: ignore[import-untyped] 14 | import six # type: ignore[import-untyped] 15 | 16 | from aws_okta_processor.core.tty import print_tty 17 | 18 | # Constants for SAML namespaces and AWS sign-in URL 19 | SAML_ATTRIBUTE = "{urn:oasis:names:tc:SAML:2.0:assertion}Attribute" 20 | SAML_ATTRIBUTE_ROLE = "https://aws.amazon.com/SAML/Attributes/Role" 21 | SAML_ATTRIBUTE_VALUE = "{urn:oasis:names:tc:SAML:2.0:assertion}AttributeValue" 22 | AWS_SIGN_IN_URL = "https://signin.aws.amazon.com/saml" 23 | 24 | 25 | def get_saml_assertion(saml_response=None): 26 | """ 27 | Extracts the SAML assertion from the HTML content. 28 | 29 | Searches for the SAMLResponse input field in the provided HTML content 30 | and returns its value. 31 | 32 | Args: 33 | saml_response (str): HTML content containing the SAMLResponse. 34 | 35 | Returns: 36 | str or None: The SAML assertion if found, otherwise None. 37 | """ 38 | soup = BeautifulSoup(saml_response, "html.parser") 39 | 40 | # Search for the SAMLResponse input field 41 | for input_tag in soup.find_all("input"): 42 | if input_tag.get("name") == "SAMLResponse": 43 | return input_tag.get("value") 44 | 45 | # Check for MFA challenge indicators 46 | if soup.find("div", {"id": "okta-sign-in"}): 47 | # The supplied Okta session is not sufficient to get the SAML assertion. 48 | # Note: This may fail if Okta changes the app-level MFA page. 49 | print_tty("SAMLResponse tag not found due to MFA challenge.") 50 | return None 51 | 52 | # Check for password verification challenge indicators 53 | if soup.find("div", {"id": "password-verification-challenge"}): 54 | # The supplied Okta session is not sufficient to get the SAML assertion. 55 | # Note: This may fail if Okta changes the app-level re-auth page. 56 | print_tty("SAMLResponse tag not found due to password verification challenge.") 57 | return None 58 | 59 | print_tty("ERROR: SAMLResponse tag was not found!") 60 | sys.exit(1) 61 | 62 | 63 | def get_aws_roles( # pylint: disable=R0914 64 | saml_assertion=None, accounts_filter=None, sign_in_url=None 65 | ): 66 | """ 67 | Parses the SAML assertion and extracts AWS roles. 68 | 69 | Args: 70 | saml_assertion (str): Base64-encoded SAML assertion. 71 | accounts_filter (str): Filter pattern to apply to account names. 72 | sign_in_url (str): AWS sign-in URL, defaults to AWS_SIGN_IN_URL. 73 | 74 | Returns: 75 | OrderedDict: Mapping of account names to dictionaries of role ARNs and AWSRole instances. 76 | """ # noqa: E501 77 | aws_roles = OrderedDict() 78 | role_principals = {} 79 | decoded_saml = base64.b64decode(saml_assertion) 80 | xml_saml = ElementTree.fromstring(decoded_saml) 81 | saml_attributes = xml_saml.iter(SAML_ATTRIBUTE) 82 | 83 | # Extract role and principal ARNs from the SAML assertion 84 | for saml_attribute in saml_attributes: 85 | if saml_attribute.get("Name") == SAML_ATTRIBUTE_ROLE: 86 | saml_attribute_values = saml_attribute.iter(SAML_ATTRIBUTE_VALUE) 87 | 88 | for saml_attribute_value in saml_attribute_values: 89 | if not saml_attribute_value.text: 90 | print_tty("ERROR: No accounts found in SAMLResponse!") 91 | sys.exit(1) 92 | 93 | # The value is a comma-separated string: "principal_arn,role_arn" 94 | principal_arn, role_arn = saml_attribute_value.text.split(",") 95 | 96 | role_principals[role_arn] = principal_arn 97 | 98 | # Skip get_account_roles if only one role returned 99 | if len(role_principals) > 1: 100 | # Retrieve account roles from AWS sign-in page 101 | account_roles = get_account_roles( 102 | saml_assertion=saml_assertion, sign_in_url=sign_in_url 103 | ) 104 | 105 | for account_role in account_roles: 106 | account_name = account_role.account_name 107 | # Apply accounts filter if provided 108 | if accounts_filter and len(accounts_filter) > 0: 109 | account_name_alias = account_name.split(" ")[1] 110 | if not fnmatch(account_name_alias, accounts_filter): 111 | continue 112 | 113 | role_arn = account_role.role_arn 114 | account_role.principal_arn = role_principals[role_arn] 115 | 116 | if account_name not in aws_roles: 117 | aws_roles[account_name] = {} 118 | 119 | aws_roles[account_name][role_arn] = account_role 120 | else: 121 | # If only one role, create default AWSRole instance 122 | account_role = AWSRole() 123 | for role_arn, principal_arn in six.iteritems(role_principals): 124 | account_role.role_arn = role_arn 125 | account_role.principal_arn = principal_arn 126 | aws_roles["default"] = {} 127 | aws_roles["default"][role_arn] = account_role 128 | 129 | return aws_roles 130 | 131 | 132 | def get_account_roles(saml_assertion=None, sign_in_url=None): 133 | """ 134 | Retrieves AWS account roles from the AWS SAML sign-in page. 135 | 136 | Args: 137 | saml_assertion (str): Base64-encoded SAML assertion. 138 | sign_in_url (str): AWS sign-in URL, defaults to AWS_SIGN_IN_URL. 139 | 140 | Returns: 141 | list: List of AWSRole instances representing available roles. 142 | """ 143 | role_accounts = [] 144 | 145 | data = {"SAMLResponse": saml_assertion, "RelayState": ""} 146 | 147 | # Post the SAML assertion to AWS sign-in URL 148 | response = requests.post(sign_in_url or AWS_SIGN_IN_URL, data=data, timeout=60) 149 | soup = BeautifulSoup(response.text, "html.parser") 150 | accounts = soup.find("fieldset").find_all( 151 | "div", attrs={"class": "saml-account"}, recursive=False 152 | ) 153 | 154 | for account in accounts: 155 | # Extract the account name 156 | account_name = account.find("div", attrs={"class": "saml-account-name"}).string 157 | 158 | # Find all roles within the account 159 | roles = account.find("div", attrs={"class": "saml-account"}).find_all( 160 | "div", attrs={"class": "saml-role"} 161 | ) 162 | 163 | for role in roles: 164 | role_arn = role.input["id"] 165 | role_description = role.label.string 166 | role_accounts.append( 167 | AWSRole( 168 | account_name=account_name, 169 | role_description=role_description, 170 | role_arn=role_arn, 171 | ) 172 | ) 173 | 174 | return role_accounts 175 | 176 | 177 | class AWSRole: # pylint: disable=R0903 178 | """ 179 | Represents an AWS role associated with an account. 180 | 181 | Attributes: 182 | account_name (str): The name of the AWS account. 183 | role_description (str): Description of the role. 184 | role_arn (str): The ARN of the role. 185 | principal_arn (str): The ARN of the principal (identity provider). 186 | """ 187 | 188 | def __init__( 189 | self, 190 | account_name=None, 191 | role_description=None, 192 | role_arn=None, 193 | principal_arn=None, 194 | ): 195 | self.account_name = account_name 196 | self.role_description = role_description 197 | self.role_arn = role_arn 198 | self.principal_arn = principal_arn 199 | -------------------------------------------------------------------------------- /tests/core/test_fetcher.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest import mock 3 | 4 | from tests.test_base import TestBase 5 | 6 | from tests.test_base import SAML_RESPONSE 7 | 8 | from mock import patch, call 9 | from mock import MagicMock 10 | 11 | from aws_okta_processor.commands.authenticate import Authenticate 12 | from aws_okta_processor.core.fetcher import SAMLFetcher 13 | 14 | 15 | # Need to add actual tests 16 | class TestFetcher(TestBase): 17 | @patch("botocore.client") 18 | @patch('aws_okta_processor.core.fetcher.print_tty') 19 | @patch('aws_okta_processor.core.fetcher.Okta') 20 | def test_fetcher( 21 | self, 22 | mock_okta, 23 | mock_print_tty, 24 | mock_client 25 | ): 26 | self.OPTIONS["--role"] = "arn:aws:iam::2:role/Role-One" 27 | mock_okta().get_saml_response.return_value = SAML_RESPONSE 28 | mock_cache = MagicMock() 29 | authenticate = Authenticate(self.OPTIONS) 30 | fetcher = SAMLFetcher(authenticate, cache=mock_cache) 31 | 32 | fetcher.fetch_credentials() 33 | 34 | @patch('aws_okta_processor.core.fetcher.SAMLFetcher._get_app_roles') 35 | def test_get_app_roles(self, mock_get_app_roles): 36 | 37 | mock_get_app_roles.return_value = ("accounts", None, "app-url", "jdoe", 'test-org') 38 | authenticate = Authenticate(self.OPTIONS) 39 | fetcher = SAMLFetcher(authenticate, cache={}) 40 | actual = fetcher.get_app_roles() 41 | 42 | self.assertEqual({ 43 | 'Accounts': 'accounts', 44 | 'Application': 'app-url', 45 | 'Organization': 'test-org', 46 | 'User': 'jdoe' 47 | }, actual) 48 | 49 | @patch("boto3.client") 50 | @patch('aws_okta_processor.core.fetcher.print_tty') 51 | @patch('aws_okta_processor.core.fetcher.prompt.print_tty') 52 | @patch('aws_okta_processor.core.fetcher.prompt.input_tty', return_value='1') 53 | @patch('aws_okta_processor.core.fetcher.Okta') 54 | def test_fetcher_should_filter_accounts( 55 | self, 56 | mock_okta, 57 | mock_prompt, 58 | mock_prompt_print_tty, 59 | mock_print_tty, 60 | mock_client 61 | ): 62 | 63 | def assume_role_side_effect(*args, **kwargs): 64 | if kwargs['RoleArn'] == 'arn:aws:iam::1:role/Role-One': 65 | return { 66 | 'Credentials': { 67 | 'AccessKeyId': 'test-key1', 68 | 'SecretAccessKey': 'test-secret1', 69 | 'SessionToken': 'test-token1', 70 | 'Expiration': datetime(2020, 4, 17, 12, 0, 0, 0) 71 | } 72 | } 73 | raise RuntimeError('invalid RoleArn') 74 | 75 | self.OPTIONS["--account-alias"] = '1*' 76 | self.OPTIONS["--pass"] = 'testpass' 77 | 78 | mock_c = mock.Mock() 79 | mock_c.assume_role_with_saml.side_effect = assume_role_side_effect 80 | mock_okta().get_saml_response.return_value = SAML_RESPONSE 81 | mock_client.return_value = mock_c 82 | 83 | authenticate = Authenticate(self.OPTIONS) 84 | fetcher = SAMLFetcher(authenticate, cache={}) 85 | 86 | creds = fetcher.fetch_credentials() 87 | self.assertDictEqual({ 88 | 'AccessKeyId': 'test-key1', 89 | 'Expiration': '2020-04-17T12:00:00', 90 | 'SecretAccessKey': 'test-secret1', 91 | 'SessionToken': 'test-token1' 92 | }, creds) 93 | 94 | self.assertEqual(5, mock_prompt_print_tty.call_count) 95 | 96 | MagicMock.assert_has_calls(mock_prompt_print_tty, [ 97 | call('Select AWS Role:'), 98 | call('Account: 1', indents=0), 99 | call('[ 1 ] Role-One', indents=1), 100 | call('[ 2 ] Role-Two', indents=1), 101 | call('Selection: ', newline=False) 102 | ]) 103 | 104 | @patch("boto3.client") 105 | @patch('aws_okta_processor.core.fetcher.print_tty') 106 | @patch('aws_okta_processor.core.fetcher.prompt.print_tty') 107 | @patch('aws_okta_processor.core.fetcher.prompt.input_tty', return_value='1') 108 | @patch('aws_okta_processor.core.fetcher.Okta') 109 | def test_fetcher_should_prompt_all_accounts( 110 | self, 111 | mock_okta, 112 | mock_prompt, 113 | mock_prompt_print_tty, 114 | mock_print_tty, 115 | mock_client 116 | ): 117 | 118 | def assume_role_side_effect(*args, **kwargs): 119 | if kwargs['RoleArn'] == 'arn:aws:iam::1:role/Role-One': 120 | return { 121 | 'Credentials': { 122 | 'AccessKeyId': 'test-key1', 123 | 'SecretAccessKey': 'test-secret1', 124 | 'SessionToken': 'test-token1', 125 | 'Expiration': datetime(2020, 4, 17, 12, 0, 0, 0) 126 | } 127 | } 128 | raise RuntimeError('invalid RoleArn') 129 | 130 | self.OPTIONS["--pass"] = 'testpass' 131 | 132 | mock_c = mock.Mock() 133 | mock_c.assume_role_with_saml.side_effect = assume_role_side_effect 134 | mock_okta().get_saml_response.return_value = SAML_RESPONSE 135 | mock_client.return_value = mock_c 136 | 137 | authenticate = Authenticate(self.OPTIONS) 138 | fetcher = SAMLFetcher(authenticate, cache={}) 139 | 140 | creds = fetcher.fetch_credentials() 141 | self.assertDictEqual({ 142 | 'AccessKeyId': 'test-key1', 143 | 'Expiration': '2020-04-17T12:00:00', 144 | 'SecretAccessKey': 'test-secret1', 145 | 'SessionToken': 'test-token1' 146 | }, creds) 147 | 148 | self.assertEqual(7, mock_prompt_print_tty.call_count) 149 | 150 | MagicMock.assert_has_calls(mock_prompt_print_tty, [ 151 | call('Select AWS Role:'), 152 | call('Account: 1', indents=0), 153 | call('[ 1 ] Role-One', indents=1), 154 | call('[ 2 ] Role-Two', indents=1), 155 | call('Account: 2', indents=0), 156 | call('[ 3 ] Role-One', indents=1), 157 | call('Selection: ', newline=False) 158 | ]) 159 | 160 | @patch("boto3.client") 161 | @patch('aws_okta_processor.core.fetcher.print_tty') 162 | @patch('aws_okta_processor.core.fetcher.prompt.print_tty') 163 | @patch('aws_okta_processor.core.fetcher.prompt.input_tty', return_value='1') 164 | @patch('aws_okta_processor.core.fetcher.Okta') 165 | def test_fetcher_should_assume_secondary_role( 166 | self, 167 | mock_okta, 168 | mock_prompt, 169 | mock_prompt_print_tty, 170 | mock_print_tty, 171 | mock_client 172 | ): 173 | 174 | self.OPTIONS["--secondary-role"] = "arn:aws:iam::1:role/Role-Two" 175 | 176 | def assume_role_saml_side_effect(*args, **kwargs): 177 | if kwargs['RoleArn'] == 'arn:aws:iam::1:role/Role-One': 178 | return { 179 | 'Credentials': { 180 | 'AccessKeyId': 'test-key1', 181 | 'SecretAccessKey': 'test-secret1', 182 | 'SessionToken': 'test-token1', 183 | 'Expiration': datetime(2020, 4, 17, 12, 0, 0, 0) 184 | } 185 | } 186 | raise RuntimeError('invalid RoleArn') 187 | 188 | def assume_role_side_effect(*args, **kwargs): 189 | if kwargs['RoleArn'] == 'arn:aws:iam::1:role/Role-Two': 190 | return { 191 | 'Credentials': { 192 | 'AccessKeyId': 'test-key2', 193 | 'SecretAccessKey': 'test-secret2', 194 | 'SessionToken': 'test-token2', 195 | 'Expiration': datetime(2020, 4, 17, 13, 0, 0, 0) 196 | } 197 | } 198 | raise RuntimeError('invalid RoleArn') 199 | 200 | self.OPTIONS["--pass"] = 'testpass' 201 | 202 | mock_c = mock.Mock() 203 | mock_c.assume_role_with_saml.side_effect = assume_role_saml_side_effect 204 | mock_c.assume_role.side_effect = assume_role_side_effect 205 | mock_okta().get_saml_response.return_value = SAML_RESPONSE 206 | mock_client.return_value = mock_c 207 | 208 | authenticate = Authenticate(self.OPTIONS) 209 | fetcher = SAMLFetcher(authenticate, cache={}) 210 | 211 | creds = fetcher.fetch_credentials() 212 | self.assertDictEqual({ 213 | 'AccessKeyId': 'test-key2', 214 | 'Expiration': '2020-04-17T13:00:00', 215 | 'SecretAccessKey': 'test-secret2', 216 | 'SessionToken': 'test-token2' 217 | }, creds) 218 | 219 | self.assertEqual(7, mock_prompt_print_tty.call_count) 220 | 221 | MagicMock.assert_has_calls(mock_prompt_print_tty, [ 222 | call('Select AWS Role:'), 223 | call('Account: 1', indents=0), 224 | call('[ 1 ] Role-One', indents=1), 225 | call('[ 2 ] Role-Two', indents=1), 226 | call('Account: 2', indents=0), 227 | call('[ 3 ] Role-One', indents=1), 228 | call('Selection: ', newline=False) 229 | ]) 230 | -------------------------------------------------------------------------------- /aws_okta_processor/core/fetcher.py: -------------------------------------------------------------------------------- 1 | """Module to fetch AWS credentials via SAML authentication with Okta.""" 2 | 3 | import sys 4 | import json 5 | import hashlib 6 | import boto3 # type: ignore[import-untyped] 7 | 8 | from botocore.credentials import CachedCredentialFetcher # type: ignore[import-untyped] 9 | 10 | from aws_okta_processor.core.okta import Okta 11 | from aws_okta_processor.core.tty import print_tty 12 | from aws_okta_processor.core import saml, prompt 13 | 14 | 15 | class SAMLFetcher(CachedCredentialFetcher): 16 | """Fetches AWS credentials via SAML authentication with Okta. 17 | 18 | This class handles the retrieval and caching of AWS temporary credentials 19 | by authenticating with Okta and using the SAML assertion to assume AWS roles. 20 | """ 21 | 22 | def __init__(self, authenticate, cache=None, expiry_window_seconds=600): 23 | """Initialize the SAMLFetcher. 24 | 25 | Args: 26 | authenticate: An authentication object that provides methods to interact with Okta. 27 | cache: An optional cache object to store and retrieve cached credentials. 28 | expiry_window_seconds: The window (in seconds) before expiry to refresh credentials. 29 | """ # noqa: E501 30 | 31 | self._authenticate = authenticate 32 | self._configuration = authenticate.configuration 33 | super().__init__(cache, expiry_window_seconds) 34 | 35 | def _create_cache_key(self): 36 | """Creates a unique cache key based on the authentication configuration. 37 | 38 | Returns: 39 | A string that uniquely identifies the authentication session. 40 | """ 41 | key_dict = self._authenticate.get_key_dict() 42 | key_string = json.dumps(key_dict, sort_keys=True) 43 | key_hash = hashlib.sha1(key_string.encode()).hexdigest() 44 | return self._make_file_safe(key_hash) 45 | 46 | def fetch_credentials(self): 47 | """Fetches AWS credentials, using cache if available. 48 | 49 | If caching is disabled or credentials are not cached, it will authenticate 50 | via Okta and retrieve new credentials. 51 | 52 | Returns: 53 | A dictionary containing AWS credentials and expiration time. 54 | """ 55 | if self._configuration["AWS_OKTA_NO_AWS_CACHE"]: 56 | # Fetch new credentials and write them to cache 57 | response = self._get_credentials() 58 | self._write_to_cache(response) 59 | 60 | # Fetch credentials from cache 61 | credentials = super().fetch_credentials() 62 | 63 | return { 64 | "AccessKeyId": credentials["access_key"], 65 | "SecretAccessKey": credentials["secret_key"], 66 | "SessionToken": credentials["token"], 67 | "Expiration": credentials["expiry_time"], 68 | } 69 | 70 | def _get_app_roles(self): 71 | """Retrieves AWS roles available to the user via Okta. 72 | 73 | Authenticates with Okta to get a SAML assertion, and parses it to get available AWS roles. 74 | 75 | Returns: 76 | A tuple containing: 77 | - List of AWS roles 78 | - SAML assertion 79 | - Application URL 80 | - User name 81 | - Organization name 82 | """ # noqa: E501 83 | user = self._configuration["AWS_OKTA_USER"] 84 | user_pass = self._authenticate.get_pass() 85 | organization = self._configuration["AWS_OKTA_ORGANIZATION"] 86 | no_okta_cache = self._configuration["AWS_OKTA_NO_OKTA_CACHE"] 87 | 88 | # Initialize Okta authentication 89 | okta = Okta( 90 | user_name=user, 91 | user_pass=user_pass, 92 | organization=organization, 93 | factor=self._configuration["AWS_OKTA_FACTOR"], 94 | silent=self._configuration["AWS_OKTA_SILENT"], 95 | no_okta_cache=no_okta_cache, 96 | ) 97 | 98 | # Clear sensitive information from configuration 99 | self._configuration["AWS_OKTA_USER"] = "" 100 | self._configuration["AWS_OKTA_PASS"] = "" 101 | 102 | if self._configuration["AWS_OKTA_APPLICATION"]: 103 | # Use specified application URL 104 | application_url = self._configuration["AWS_OKTA_APPLICATION"] 105 | else: 106 | # Prompt user to select an application 107 | applications = okta.get_applications() 108 | application_url = prompt.get_item( 109 | items=applications, 110 | label="AWS application", 111 | key=self._configuration["AWS_OKTA_APPLICATION"], 112 | ) 113 | 114 | # Get SAML response from Okta 115 | saml_response = okta.get_saml_response(application_url=application_url) 116 | saml_assertion = saml.get_saml_assertion(saml_response=saml_response) 117 | 118 | if not saml_assertion and not no_okta_cache: 119 | # Retry without using Okta cache 120 | print_tty("Creating new Okta session.") 121 | okta = Okta( 122 | user_name=user, 123 | user_pass=user_pass, 124 | organization=organization, 125 | factor=self._configuration["AWS_OKTA_FACTOR"], 126 | silent=self._configuration["AWS_OKTA_SILENT"], 127 | no_okta_cache=True, 128 | ) 129 | saml_response = okta.get_saml_response(application_url=application_url) 130 | saml_assertion = saml.get_saml_assertion(saml_response=saml_response) 131 | 132 | if not saml_assertion: 133 | # Unable to retrieve SAML assertion 134 | print_tty("ERROR: SAMLResponse tag was not found!") 135 | sys.exit(1) 136 | 137 | # Parse SAML assertion to get AWS roles 138 | aws_roles = saml.get_aws_roles( 139 | saml_assertion=saml_assertion, 140 | accounts_filter=self._configuration.get("AWS_OKTA_ACCOUNT_ALIAS", None), 141 | sign_in_url=self._configuration.get("AWS_OKTA_SIGN_IN_URL", None), 142 | ) 143 | 144 | return ( 145 | aws_roles, 146 | saml_assertion, 147 | application_url, 148 | okta.user_name, 149 | okta.organization, 150 | ) 151 | 152 | def get_app_roles(self): 153 | """Public method to get available AWS roles. 154 | 155 | Returns: 156 | A dictionary containing: 157 | - Application URL 158 | - List of AWS accounts and roles 159 | - User name 160 | - Organization name 161 | """ 162 | aws_roles, _, application_url, user, organization = self._get_app_roles() 163 | return { 164 | "Application": application_url, 165 | "Accounts": aws_roles, 166 | "User": user, 167 | "Organization": organization, 168 | } 169 | 170 | def _get_credentials(self): 171 | """Retrieves AWS temporary credentials by assuming an AWS role via SAML. 172 | 173 | Returns: 174 | A dictionary containing AWS credentials and expiration time. 175 | """ 176 | # Do NOT load credentials from ENV or ~/.aws/credentials 177 | client = boto3.client( 178 | "sts", 179 | aws_access_key_id="", 180 | aws_secret_access_key="", 181 | aws_session_token="", 182 | region_name=self._configuration["AWS_OKTA_REGION"], 183 | ) 184 | 185 | # Get available AWS roles and SAML assertion 186 | aws_roles, saml_assertion, _application_url, user, _organization = ( 187 | self._get_app_roles() 188 | ) 189 | 190 | # Prompt user to select an AWS role 191 | aws_role = prompt.get_item( 192 | items=aws_roles, label="AWS Role", key=self._configuration["AWS_OKTA_ROLE"] 193 | ) 194 | 195 | print_tty( 196 | f"Role: {aws_role.role_arn}", silent=self._configuration["AWS_OKTA_SILENT"] 197 | ) 198 | 199 | # Assume the selected role using the SAML assertion 200 | response = client.assume_role_with_saml( 201 | RoleArn=aws_role.role_arn, 202 | PrincipalArn=aws_role.principal_arn, 203 | SAMLAssertion=saml_assertion, 204 | DurationSeconds=int(self._configuration["AWS_OKTA_DURATION"]), 205 | ) 206 | 207 | if self._configuration.get("AWS_OKTA_SECONDARY_ROLE", None) is not None: 208 | # If secondary role is specified, assume it 209 | role_session_name = user 210 | secondary_role_arn = self._configuration["AWS_OKTA_SECONDARY_ROLE"] 211 | 212 | print_tty(f"Assuming secondary role {secondary_role_arn}") 213 | credentials = response["Credentials"] 214 | client = boto3.client( 215 | "sts", 216 | aws_access_key_id=credentials["AccessKeyId"], 217 | aws_secret_access_key=credentials["SecretAccessKey"], 218 | aws_session_token=credentials["SessionToken"], 219 | region_name=self._configuration["AWS_OKTA_REGION"], 220 | ) 221 | response = client.assume_role( 222 | RoleArn=secondary_role_arn, 223 | DurationSeconds=int(self._configuration["AWS_OKTA_DURATION"]), 224 | RoleSessionName=role_session_name, 225 | ) 226 | 227 | # Format expiration time 228 | expiration = ( 229 | response["Credentials"]["Expiration"].isoformat().replace("+00:00", "Z") 230 | ) 231 | response["Credentials"]["Expiration"] = expiration 232 | 233 | return response 234 | -------------------------------------------------------------------------------- /aws_okta_processor/commands/authenticate.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=C0301 2 | """ 3 | Module for AWS-Okta authentication processing. 4 | 5 | This module defines the 'Authenticate' class, which handles the process of authenticating with Okta 6 | and obtaining AWS credentials via SAML. 7 | 8 | Usage: 9 | aws-okta-processor authenticate [options] 10 | 11 | Options: 12 | -h --help Show this screen. 13 | --version Show version. 14 | --no-okta-cache Do not read Okta cache. 15 | --no-aws-cache Do not read AWS cache. 16 | -e --environment Dump auth into ENV variables. 17 | -u , --user= Okta user name. 18 | -p , --pass= Okta user password. 19 | -o , --organization= Okta organization domain. 20 | -a , --application= Okta application URL. 21 | -r , --role= AWS role ARN. 22 | --secondary-role Secondary AWS role ARN. 23 | -R , --region= AWS region name. 24 | -U , --sign-in-url= AWS Sign In URL. 25 | [default: https://signin.aws.amazon.com/saml] 26 | -A , --account-alias= AWS account alias filter (uses wildcards). 27 | -d , --duration= Duration of role session [default: 3600]. 28 | -k , --key= Key used for generating and accessing cache. 29 | -f --factor= Factor type for MFA. 30 | -s --silent Run silently. 31 | --target-shell Target shell to output the export command. 32 | """ # noqa: E501 33 | 34 | from __future__ import print_function 35 | 36 | import os 37 | import json 38 | 39 | from botocore.credentials import JSONFileCache # type: ignore[import-untyped] 40 | 41 | from aws_okta_processor.core.fetcher import SAMLFetcher 42 | 43 | from .base import Base 44 | 45 | # Shell command templates for exporting AWS credentials in different shells. 46 | 47 | # Template for UNIX-like shells (bash, zsh) 48 | UNIX_EXPORT_STRING = ( 49 | "export AWS_ACCESS_KEY_ID='{}' && " 50 | "export AWS_SECRET_ACCESS_KEY='{}' && " 51 | "export AWS_SESSION_TOKEN='{}' && " 52 | "export AWS_CREDENTIAL_EXPIRATION='{}'" 53 | ) 54 | 55 | # Template for Fish shell 56 | UNIX_FISH_EXPORT_STRING = ( 57 | "set --export AWS_ACCESS_KEY_ID '{}'; and " 58 | "set --export AWS_SECRET_ACCESS_KEY '{}'; and " 59 | "set --export AWS_SESSION_TOKEN '{}'; and " 60 | "set --export AWS_CREDENTIAL_EXPIRATION '{}';" 61 | ) 62 | 63 | # Template for Windows PowerShell 64 | NT_EXPORT_STRING = ( 65 | "$env:AWS_ACCESS_KEY_ID='{}'; " 66 | "$env:AWS_SECRET_ACCESS_KEY='{}'; " 67 | "$env:AWS_SESSION_TOKEN='{}'; " 68 | "$env:AWS_CREDENTIAL_EXPIRATION='{}'" 69 | ) 70 | 71 | # Map command-line options to environment variable names. 72 | CONFIG_MAP = { 73 | "--environment": "AWS_OKTA_ENVIRONMENT", 74 | "--user": "AWS_OKTA_USER", 75 | "--pass": "AWS_OKTA_PASS", 76 | "--organization": "AWS_OKTA_ORGANIZATION", 77 | "--application": "AWS_OKTA_APPLICATION", 78 | "--role": "AWS_OKTA_ROLE", 79 | "--secondary-role": "AWS_OKTA_SECONDARY_ROLE", 80 | "--region": "AWS_OKTA_REGION", 81 | "--sign-in-url": "AWS_OKTA_SIGN_IN_URL", 82 | "--duration": "AWS_OKTA_DURATION", 83 | "--key": "AWS_OKTA_KEY", 84 | "--factor": "AWS_OKTA_FACTOR", 85 | "--silent": "AWS_OKTA_SILENT", 86 | "--no-okta-cache": "AWS_OKTA_NO_OKTA_CACHE", 87 | "--no-aws-cache": "AWS_OKTA_NO_AWS_CACHE", 88 | "--account-alias": "AWS_OKTA_ACCOUNT_ALIAS", 89 | "--target-shell": "AWS_OKTA_TARGET_SHELL", 90 | } 91 | 92 | # Map environment variables to internal configuration keys. 93 | EXTEND_CONFIG_MAP = { 94 | "AWS_OKTA_ENVIRONMENT": "environment", 95 | "AWS_OKTA_USER": "user", 96 | "AWS_OKTA_PASS": "pass", 97 | "AWS_OKTA_ORGANIZATION": "organization", 98 | "AWS_OKTA_APPLICATION": "application", 99 | "AWS_OKTA_ROLE": "role", 100 | "AWS_OKTA_SECONDARY_ROLE": "secondary-role", 101 | "AWS_OKTA_REGION": "region", 102 | "AWS_OKTA_SIGN_IN_URL": "sign_in_url", 103 | "AWS_OKTA_DURATION": "duration", 104 | "AWS_OKTA_KEY": "key", 105 | "AWS_OKTA_FACTOR": "factor", 106 | "AWS_OKTA_SILENT": "silent", 107 | "AWS_OKTA_NO_OKTA_CACHE": "no-okta-cache", 108 | "AWS_OKTA_NO_AWS_CACHE": "no-aws-cache", 109 | "AWS_OKTA_ACCOUNT_ALIAS": "account-alias", 110 | "AWS_OKTA_TARGET_SHELL": "target-shell", 111 | } 112 | 113 | 114 | class Authenticate(Base): 115 | """ 116 | Authenticates with Okta to obtain AWS credentials via SAML and outputs them 117 | in the desired format. 118 | 119 | Inherits from: 120 | Base: The base class that provides common functionality for commands. 121 | """ 122 | 123 | def authenticate(self): 124 | """ 125 | Authenticates with Okta and fetches AWS credentials. 126 | 127 | Returns: 128 | dict: A dictionary containing AWS credentials. 129 | """ 130 | cache = JSONFileCache() 131 | saml_fetcher = SAMLFetcher(self, cache=cache) 132 | 133 | credentials = saml_fetcher.fetch_credentials() 134 | 135 | return credentials 136 | 137 | def run(self): 138 | """ 139 | Main entry point for the 'authenticate' command. 140 | 141 | Authenticates with Okta, fetches AWS credentials, and outputs them 142 | either as environment variables or as JSON, depending on the configuration. 143 | """ 144 | credentials = self.authenticate() 145 | 146 | if self.configuration["AWS_OKTA_ENVIRONMENT"]: 147 | if os.name == "nt": 148 | print(self.nt_output(credentials)) 149 | else: 150 | print(self.unix_output(credentials)) 151 | else: 152 | credentials["Version"] = 1 153 | print(json.dumps(credentials)) 154 | 155 | def nt_output(self, credentials): 156 | """ 157 | Generates the export command for Windows-based systems. 158 | 159 | Args: 160 | credentials (dict): AWS credentials. 161 | 162 | Returns: 163 | str: A command string to set environment variables in PowerShell. 164 | """ 165 | return NT_EXPORT_STRING.format( 166 | credentials["AccessKeyId"], 167 | credentials["SecretAccessKey"], 168 | credentials["SessionToken"], 169 | credentials["Expiration"] 170 | ) 171 | 172 | def unix_output(self, credentials): 173 | """ 174 | Generates the export command for UNIX-based systems. 175 | 176 | Determines the target shell from the configuration and formats the 177 | appropriate export command. 178 | 179 | Args: 180 | credentials (dict): AWS credentials. 181 | 182 | Returns: 183 | str: A command string to set environment variables in the shell. 184 | """ 185 | # Assume Bash as the default shell target 186 | export_string = UNIX_EXPORT_STRING 187 | 188 | if self.configuration["AWS_OKTA_TARGET_SHELL"] == "fish": 189 | export_string = UNIX_FISH_EXPORT_STRING 190 | 191 | return export_string.format( 192 | credentials["AccessKeyId"], 193 | credentials["SecretAccessKey"], 194 | credentials["SessionToken"], 195 | credentials["Expiration"] 196 | ) 197 | 198 | def get_pass(self): 199 | """ 200 | Retrieves the Okta user password from the configuration. 201 | 202 | Returns: 203 | str or None: The Okta user password if set, otherwise None. 204 | """ 205 | if self.configuration["AWS_OKTA_PASS"]: 206 | return self.configuration["AWS_OKTA_PASS"] 207 | 208 | return None 209 | 210 | def get_key_dict(self): 211 | """ 212 | Constructs a dictionary key for caching purposes. 213 | 214 | Returns: 215 | dict: A dictionary containing 'Organization', 'User', and 'Key' entries. 216 | """ 217 | return { 218 | "Organization": self.configuration["AWS_OKTA_ORGANIZATION"], 219 | "User": self.configuration["AWS_OKTA_USER"], 220 | "Key": self.configuration["AWS_OKTA_KEY"], 221 | } 222 | 223 | def get_configuration(self, options=None): 224 | """ 225 | Builds the configuration dictionary from options and environment variables. 226 | 227 | Args: 228 | options (dict, optional): Command-line options parsed by docopt. 229 | 230 | Returns: 231 | dict: A configuration dictionary. 232 | """ 233 | configuration = {} 234 | 235 | for param, var in CONFIG_MAP.items(): 236 | if options.get(param, None): 237 | configuration[var] = options[param] 238 | 239 | if var not in configuration: 240 | if var in os.environ: 241 | configuration[var] = os.environ[var] 242 | else: 243 | configuration[var] = None 244 | 245 | return self.extend_configuration( 246 | configuration, "authenticate", EXTEND_CONFIG_MAP 247 | ) 248 | -------------------------------------------------------------------------------- /tests/core/test_tty.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import unittest 4 | 5 | from unittest import TestCase 6 | from mock import patch 7 | from mock import call 8 | from mock import MagicMock 9 | 10 | import aws_okta_processor.core.tty as tty 11 | 12 | 13 | class UnixTtyTests(TestCase): 14 | @patch('aws_okta_processor.core.tty.import_msvcrt') 15 | @patch('aws_okta_processor.core.tty.contextlib2') 16 | @patch('aws_okta_processor.core.tty.os') 17 | @patch('aws_okta_processor.core.tty.io') 18 | def test_unix_print_tty( 19 | self, 20 | mock_io, 21 | mock_os, 22 | mock_conextlib2, 23 | mock_import_msvcrt 24 | ): 25 | mock_import_msvcrt.side_effect = ImportError 26 | mock_stack = MagicMock() 27 | mock_conextlib2.ExitStack.return_value = mock_stack 28 | mock_text_wrapper = MagicMock() 29 | mock_io.TextIOWrapper.return_value = mock_text_wrapper 30 | 31 | calls = [ 32 | call(u'STRING'), 33 | call(u'\n') 34 | ] 35 | 36 | tty.print_tty("STRING") 37 | mock_text_wrapper.write.assert_has_calls(calls) 38 | mock_os.open.assert_called_once() 39 | 40 | @patch('aws_okta_processor.core.tty.import_msvcrt') 41 | @patch('aws_okta_processor.core.tty.contextlib2') 42 | @patch('aws_okta_processor.core.tty.os') 43 | @patch('aws_okta_processor.core.tty.io') 44 | def test_unix_print_tty_no_newline( 45 | self, 46 | mock_io, 47 | mock_os, 48 | mock_conextlib2, 49 | mock_import_msvcrt 50 | ): 51 | mock_import_msvcrt.side_effect = ImportError 52 | mock_stack = MagicMock() 53 | mock_conextlib2.ExitStack.return_value.__enter__.return_value = mock_stack # noqa 54 | mock_text_wrapper = MagicMock() 55 | mock_io.TextIOWrapper.return_value = mock_text_wrapper 56 | 57 | tty.print_tty("STRING", newline=False) 58 | mock_os.open.assert_called_once() 59 | mock_text_wrapper.write.assert_called_once_with(u'STRING') 60 | mock_text_wrapper.flush.assert_called_once() 61 | 62 | @patch('aws_okta_processor.core.tty.import_msvcrt') 63 | @patch('aws_okta_processor.core.tty.contextlib2') 64 | @patch('aws_okta_processor.core.tty.os') 65 | @patch('aws_okta_processor.core.tty.io') 66 | def test_unix_print_tty_indent( 67 | self, 68 | mock_io, 69 | mock_os, 70 | mock_conextlib2, 71 | mock_import_msvcrt 72 | ): 73 | mock_import_msvcrt.side_effect = ImportError 74 | mock_stack = MagicMock() 75 | mock_conextlib2.ExitStack.return_value.__enter__.return_value = mock_stack # noqa 76 | mock_text_wrapper = MagicMock() 77 | mock_io.TextIOWrapper.return_value = mock_text_wrapper 78 | 79 | tty.print_tty("STRING", indents=1, newline=False) 80 | mock_os.open.assert_called_once() 81 | mock_text_wrapper.write.assert_called_once_with(u' STRING') 82 | mock_text_wrapper.flush.assert_called_once() 83 | 84 | @patch('aws_okta_processor.core.tty.import_msvcrt') 85 | @patch('aws_okta_processor.core.tty.sys.stdout') 86 | @patch('aws_okta_processor.core.tty.contextlib2') 87 | @patch('aws_okta_processor.core.tty.os') 88 | @patch('aws_okta_processor.core.tty.io') 89 | def test_unix_print_tty_print( 90 | self, 91 | mock_io, 92 | mock_os, 93 | mock_conextlib2, 94 | mock_print, 95 | mock_import_msvcrt 96 | ): 97 | mock_import_msvcrt.side_effect = ImportError 98 | mock_stack = MagicMock() 99 | mock_conextlib2.ExitStack.return_value.__enter__.return_value = mock_stack # noqa 100 | mock_text_wrapper = MagicMock() 101 | mock_io.TextIOWrapper.return_value = mock_text_wrapper 102 | mock_os.open.side_effect = OSError 103 | 104 | calls = [ 105 | call(u'STRING'), 106 | call(u'\n') 107 | ] 108 | 109 | tty.print_tty("STRING") 110 | mock_print.write.assert_has_calls(calls) 111 | mock_stack.close.assert_called_once() 112 | mock_text_wrapper.write.assert_not_called() 113 | 114 | @patch('aws_okta_processor.core.tty.import_msvcrt') 115 | @patch('aws_okta_processor.core.tty.sys.stdout') 116 | @patch('aws_okta_processor.core.tty.contextlib2') 117 | @patch('aws_okta_processor.core.tty.os') 118 | @patch('aws_okta_processor.core.tty.io') 119 | def test_unix_print_tty_print_no_newline( 120 | self, 121 | mock_io, 122 | mock_os, 123 | mock_conextlib2, 124 | mock_print, 125 | mock_import_msvcrt 126 | ): 127 | mock_import_msvcrt.side_effect = ImportError 128 | mock_stack = MagicMock() 129 | mock_conextlib2.ExitStack.return_value.__enter__.return_value = mock_stack # noqa 130 | mock_text_wrapper = MagicMock() 131 | mock_io.TextIOWrapper.return_value = mock_text_wrapper 132 | mock_os.open.side_effect = OSError 133 | 134 | tty.print_tty("STRING", newline=False) 135 | mock_print.write.assert_called_once_with("STRING") 136 | mock_stack.close.assert_called_once() 137 | mock_text_wrapper.write.assert_not_called() 138 | 139 | @patch('aws_okta_processor.core.tty.import_msvcrt') 140 | @patch('aws_okta_processor.core.tty.sys.stdout') 141 | @patch('aws_okta_processor.core.tty.contextlib2') 142 | @patch('aws_okta_processor.core.tty.os') 143 | @patch('aws_okta_processor.core.tty.io') 144 | def test_unix_print_tty_print_indent( 145 | self, 146 | mock_io, 147 | mock_os, 148 | mock_conextlib2, 149 | mock_print, 150 | mock_import_msvcrt 151 | ): 152 | mock_import_msvcrt.side_effect = ImportError 153 | mock_stack = MagicMock() 154 | mock_conextlib2.ExitStack.return_value.__enter__.return_value = mock_stack # noqa 155 | mock_text_wrapper = MagicMock() 156 | mock_io.TextIOWrapper.return_value = mock_text_wrapper 157 | mock_os.open.side_effect = OSError 158 | 159 | tty.print_tty("STRING", indents=1, newline=False) 160 | mock_print.write.assert_called_once_with(" STRING") 161 | mock_stack.close.assert_called_once() 162 | mock_text_wrapper.write.assert_not_called() 163 | 164 | @patch('aws_okta_processor.core.tty.import_msvcrt') 165 | @patch('aws_okta_processor.core.tty.os') 166 | @patch('aws_okta_processor.core.tty.io.FileIO') 167 | @patch('aws_okta_processor.core.tty.io.TextIOWrapper') 168 | def test_unix_input_tty( 169 | self, 170 | mock_textio, 171 | mock_fileio, 172 | mock_os, 173 | mock_import_msvcrt 174 | ): 175 | mock_import_msvcrt.side_effect = ImportError 176 | 177 | tty.input_tty() 178 | mock_fileio.assert_called_once_with(mock_os.open.return_value, 'r+') 179 | mock_textio.assert_called_once_with(mock_fileio.return_value) 180 | 181 | @patch('aws_okta_processor.core.tty.import_msvcrt') 182 | @patch('aws_okta_processor.core.tty.os') 183 | @patch('aws_okta_processor.core.tty.sys.stdin.readline') 184 | def test_unix_input_tty_input( 185 | self, 186 | mock_readline, 187 | mock_os, 188 | mock_import_msvcrt 189 | ): 190 | mock_import_msvcrt.side_effect = ImportError 191 | mock_os.open.side_effect = IOError 192 | mock_readline.return_value = 'return-value' 193 | 194 | actual = tty.input_tty() 195 | 196 | self.assertEqual(actual, 'return-value') 197 | 198 | 199 | class WindowsTtyTests(unittest.TestCase): 200 | @patch('aws_okta_processor.core.tty.import_msvcrt') 201 | def test_win_print_tty(self, mock_import_msvcrt): 202 | mock_msvcrt = MagicMock() 203 | mock_import_msvcrt.return_value = mock_msvcrt 204 | calls = ([], []) 205 | for char in list("STRING\r\n"): 206 | calls[0].append(call(char)) 207 | calls[1].append(call(bytes(char.encode()))) 208 | 209 | tty.print_tty("STRING") 210 | 211 | try: 212 | mock_msvcrt.putch.assert_has_calls(calls[0]) 213 | except AssertionError: 214 | mock_msvcrt.putch.assert_has_calls(calls[1]) 215 | 216 | @patch('aws_okta_processor.core.tty.import_msvcrt') 217 | def test_win_print_tty_no_newline(self, mock_import_msvcrt): 218 | mock_msvcrt = MagicMock() 219 | mock_import_msvcrt.return_value = mock_msvcrt 220 | calls = ([], []) 221 | for char in list("STRING"): 222 | calls[0].append(call(char)) 223 | calls[1].append(call(bytes(char.encode()))) 224 | 225 | tty.print_tty("STRING", newline=False) 226 | mock_import_msvcrt.assert_called() 227 | 228 | try: 229 | mock_msvcrt.putch.assert_has_calls(calls[0]) 230 | except AssertionError: 231 | mock_msvcrt.putch.assert_has_calls(calls[1]) 232 | 233 | @patch('aws_okta_processor.core.tty.import_msvcrt') 234 | def test_win_print_tty_indent(self, mock_import_msvcrt): 235 | mock_msvcrt = MagicMock() 236 | mock_import_msvcrt.return_value = mock_msvcrt 237 | calls = ([], []) 238 | for char in list(" STRING"): 239 | calls[0].append(call(char)) 240 | calls[1].append(call(bytes(char.encode()))) 241 | 242 | tty.print_tty("STRING", indents=1, newline=False) 243 | mock_import_msvcrt.assert_called() 244 | 245 | try: 246 | mock_msvcrt.putch.assert_has_calls(calls[0]) 247 | except AssertionError: 248 | mock_msvcrt.putch.assert_has_calls(calls[1]) 249 | 250 | @patch('aws_okta_processor.core.tty.import_msvcrt') 251 | def test_win_input_tty(self, mock_import_msvcrt): 252 | mock_msvcrt = MagicMock() 253 | mock_import_msvcrt.return_value = mock_msvcrt 254 | mock_msvcrt.getwch.side_effect = ['a','b','c','\n'] 255 | actual = tty.input_tty() 256 | 257 | self.assertEqual(actual, 'abc') 258 | -------------------------------------------------------------------------------- /aws_okta_processor/commands/getroles.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=C0301 2 | """ 3 | Usage: aws-okta-processor get-roles [options] 4 | 5 | Options: 6 | -h --help Show this screen. 7 | --version Show version. 8 | --no-okta-cache Do not read okta cache. 9 | --no-aws-cache Do not read aws cache. 10 | -e --environment Dump auth into ENV variables. 11 | -u , --user= Okta user name. 12 | -p , --pass= Okta user password. 13 | -o , --organization= Okta organization domain. 14 | -a , --application= Okta application url. 15 | -r , --role= AWS role ARN. 16 | -R , --region= AWS region name. 17 | -U , --sign-in-url= AWS Sign In URL. 18 | [default: https://signin.aws.amazon.com/saml] 19 | -A , --account-alias= AWS account alias filter (uses wildcards). 20 | -d ,--duration= Duration of role session [default: 3600]. 21 | -k , --key= Key used for generating and accessing cache. 22 | -f , --factor= Factor type for MFA. 23 | -s --silent Run silently. 24 | --target-shell Target shell to output the export command. 25 | --output= Output type (json, text, profiles) [default: json] 26 | --output-format= Format string for the output 27 | [default: {account},{role}] 28 | """ # noqa: E501 29 | 30 | from __future__ import print_function 31 | 32 | import os 33 | import json 34 | import re 35 | import sys 36 | 37 | from botocore.credentials import JSONFileCache # type: ignore[import-untyped] 38 | 39 | from aws_okta_processor.core.fetcher import SAMLFetcher 40 | 41 | from .base import Base 42 | 43 | # Command to export AWS credentials in Unix shell 44 | UNIX_EXPORT_STRING = ( 45 | "export AWS_ACCESS_KEY_ID='{}' && " 46 | "export AWS_SECRET_ACCESS_KEY='{}' && " 47 | "export AWS_SESSION_TOKEN='{}'" 48 | ) 49 | 50 | # Command to export AWS credentials in Windows PowerShell 51 | NT_EXPORT_STRING = ( 52 | "$env:AWS_ACCESS_KEY_ID='{}'; " 53 | "$env:AWS_SECRET_ACCESS_KEY='{}'; " 54 | "$env:AWS_SESSION_TOKEN='{}'" 55 | ) 56 | 57 | # Mapping of command-line options to environment variable names 58 | CONFIG_MAP = { 59 | "--environment": "AWS_OKTA_ENVIRONMENT", 60 | "--user": "AWS_OKTA_USER", 61 | "--pass": "AWS_OKTA_PASS", 62 | "--organization": "AWS_OKTA_ORGANIZATION", 63 | "--application": "AWS_OKTA_APPLICATION", 64 | "--role": "AWS_OKTA_ROLE", 65 | "--duration": "AWS_OKTA_DURATION", 66 | "--key": "AWS_OKTA_KEY", 67 | "--factor": "AWS_OKTA_FACTOR", 68 | "--silent": "AWS_OKTA_SILENT", 69 | "--no-okta-cache": "AWS_OKTA_NO_OKTA_CACHE", 70 | "--no-aws-cache": "AWS_OKTA_NO_AWS_CACHE", 71 | "--output": "AWS_OKTA_OUTPUT", 72 | "--output-format": "AWS_OKTA_OUTPUT_FORMAT", 73 | } 74 | 75 | 76 | class GetRoles(Base): 77 | """ 78 | Class to handle the 'get-roles' command for aws-okta-processor. 79 | Retrieves AWS accounts and roles available to the Okta user and outputs them in the specified format. 80 | """ # noqa: E501 81 | 82 | def get_accounts_and_roles(self): # pylint: disable=R0914 83 | """ 84 | Retrieves the list of AWS accounts and roles available to the Okta user by fetching the SAML assertion 85 | from Okta and parsing the AWS accounts and roles included in it. 86 | 87 | Returns: 88 | dict: A dictionary containing: 89 | - 'application_url' (str): The Okta application URL. 90 | - 'accounts' (list): A list of accounts, each account being a dict with: 91 | - 'name' (str): Account name. 92 | - 'id' (str): Account ID. 93 | - 'name_raw' (str): Raw account name string from Okta. 94 | - 'roles' (list): A list of roles, each role being a dict with: 95 | - 'name' (str): Full role ARN. 96 | - 'suffix' (str): The suffix of the role name, extracted from the full role ARN. 97 | - 'user' (str): The Okta username. 98 | - 'organization' (str): The Okta organization domain. 99 | """ # noqa: E501 100 | cache = JSONFileCache() 101 | saml_fetcher = SAMLFetcher(self, cache=cache) 102 | 103 | # Fetch the application and roles from Okta 104 | app_and_role = saml_fetcher.get_app_roles() 105 | 106 | result_accounts = [] 107 | results = { 108 | "application_url": app_and_role["Application"], 109 | "accounts": result_accounts, 110 | "user": app_and_role["User"], 111 | "organization": app_and_role["Organization"], 112 | } 113 | 114 | accounts = app_and_role["Accounts"] 115 | for name_raw in accounts: 116 | # Example of name_raw: 'Account: my-account (123456789012)' 117 | # Extract account name and ID from the raw account name 118 | account_parts = re.match( 119 | r"(Account:) ([a-zA-Z0-9-_]+) \(([0-9]+)\)", name_raw 120 | ) 121 | account = account_parts[2] # 'my-account' 122 | account_id = account_parts[3] # '123456789012' 123 | roles = accounts[name_raw] 124 | result_roles = [] 125 | result_account = { 126 | "name": account, 127 | "id": account_id, 128 | "name_raw": name_raw, 129 | "roles": result_roles, 130 | } 131 | result_accounts.append(result_account) 132 | for role in roles: 133 | # Get the role suffix based on delimiter (default is '-') 134 | role_suffix = role.split( 135 | os.environ.get("AWS_OKTA_ROLE_SUFFIX_DELIMITER", "-") 136 | )[-1] 137 | result_roles.append({"name": role, "suffix": role_suffix}) 138 | 139 | return results 140 | 141 | def run(self): 142 | """ 143 | Executes the 'get-roles' command, fetching accounts and roles and outputting them in the specified format. 144 | 145 | The output format can be JSON, text, or AWS profiles format. 146 | 147 | If the output is 'json', outputs the accounts and roles data as a JSON string. 148 | 149 | If the output is 'profiles', generates AWS CLI profile configurations using the 'credential_process' method. 150 | 151 | Otherwise, formats the output using the specified output format string. 152 | """ # noqa: E501 153 | accounts_and_roles = self.get_accounts_and_roles() 154 | 155 | output = self.configuration.get("AWS_OKTA_OUTPUT", "json").lower() 156 | if output == "json": 157 | sys.stdout.write(json.dumps(accounts_and_roles)) 158 | else: 159 | output_format = self.configuration.get( 160 | "AWS_OKTA_OUTPUT_FORMAT", "{account},{role}" 161 | ) 162 | if output == "profiles": 163 | output_format = ( 164 | "\n[{account}-{role_suffix}]" 165 | "\ncredential_process=aws-okta-processor authenticate " 166 | '--organization="{organization}" --user="{user}" ' 167 | '--application="{application_url}" ' 168 | '--role="{role}" --key="{account}-{role}"' 169 | ) 170 | # Generate formatted output for each role 171 | formatted_roles = self.get_formatted_roles( 172 | accounts_and_roles, output_format 173 | ) 174 | for role in formatted_roles: 175 | sys.stdout.write(role + "\n") 176 | 177 | def get_formatted_roles(self, accounts_and_roles, output_format): 178 | """ 179 | Formats the accounts and roles data according to the specified output format string. 180 | 181 | The output format string can include placeholders: 182 | - {account}: Account name. 183 | - {account_id}: Account ID. 184 | - {account_raw}: Raw account name string from Okta. 185 | - {role}: Full role ARN. 186 | - {role_suffix}: Suffix of the role name. 187 | - {organization}: Okta organization domain. 188 | - {application_url}: Okta application URL. 189 | - {user}: Okta username. 190 | 191 | Args: 192 | accounts_and_roles (dict): The accounts and roles data obtained from get_accounts_and_roles(). 193 | output_format (str): The format string to use for output. 194 | 195 | Yields: 196 | str: Formatted string for each role in the accounts. 197 | """ # noqa: E501 198 | application_url = accounts_and_roles["application_url"] 199 | accounts = accounts_and_roles["accounts"] 200 | organization = accounts_and_roles["organization"] 201 | user = accounts_and_roles["user"] 202 | 203 | for account in accounts: 204 | account_name = account["name"] 205 | account_id = account["id"] 206 | account_raw = account["name_raw"] 207 | roles = account["roles"] 208 | for role in roles: 209 | yield output_format.format( 210 | account=account_name, 211 | account_id=account_id, 212 | account_raw=account_raw, 213 | role=role["name"], 214 | organization=organization, 215 | application_url=application_url, 216 | user=user, 217 | role_suffix=role["suffix"].lower(), 218 | ) 219 | 220 | def get_pass(self): 221 | """ 222 | Retrieves the Okta user password from the configuration. 223 | 224 | Returns: 225 | str: The Okta user password if set, otherwise None. 226 | """ 227 | if self.configuration["AWS_OKTA_PASS"]: 228 | return self.configuration["AWS_OKTA_PASS"] 229 | 230 | return None 231 | 232 | def get_key_dict(self): 233 | """ 234 | Builds a dictionary containing the organization, user, and key for caching purposes. 235 | 236 | Returns: 237 | dict: Dictionary with 'Organization', 'User', and 'Key' keys. 238 | """ # noqa: E501 239 | return { 240 | "Organization": self.configuration["AWS_OKTA_ORGANIZATION"], 241 | "User": self.configuration["AWS_OKTA_USER"], 242 | "Key": self.configuration["AWS_OKTA_KEY"], 243 | } 244 | 245 | def get_configuration(self, options=None): 246 | """ 247 | Retrieves the configuration by combining command-line options, environment variables, and defaults. 248 | 249 | Args: 250 | options (dict, optional): Command-line options passed to the script. 251 | 252 | Returns: 253 | dict: A dictionary of configuration variables. 254 | """ # noqa: E501 255 | configuration = {} 256 | 257 | for param, var in CONFIG_MAP.items(): 258 | if options.get(param, None): 259 | configuration[var] = options[param] 260 | 261 | if var not in configuration: 262 | if var in os.environ: 263 | configuration[var] = os.environ[var] 264 | else: 265 | configuration[var] = None 266 | 267 | return configuration 268 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | aws-okta-processor 3 | ================== 4 | 5 | .. image:: https://github.com/godaddy/aws-okta-processor/workflows/.github/workflows/build.yml/badge.svg?branch=master 6 | :target: https://github.com/godaddy/aws-okta-processor/actions?query=workflow%3A.github%2Fworkflows%2Fbuild.yml 7 | :alt: Build Status 8 | 9 | .. image:: https://codecov.io/gh/godaddy/aws-okta-processor/branch/master/graph/badge.svg 10 | :target: https://codecov.io/gh/godaddy/aws-okta-processor 11 | :alt: Coverage 12 | 13 | .. image:: https://img.shields.io/pypi/v/aws-okta-processor.svg 14 | :target: https://pypi.python.org/pypi/aws-okta-processor 15 | :alt: Latest Version 16 | 17 | .. image:: https://img.shields.io/pypi/status/aws-okta-processor 18 | :target: https://pypi.python.org/pypi/aws-okta-processor 19 | :alt: Status 20 | 21 | .. image:: https://img.shields.io/pypi/pyversions/aws-okta-processor 22 | :target: https://pypi.python.org/pypi/aws-okta-processor 23 | :alt: Python Version 24 | 25 | .. image:: https://img.shields.io/pypi/dm/aws-okta-processor 26 | :target: https://pypi.python.org/pypi/aws-okta-processor 27 | :alt: Downloads 28 | 29 | This package provides a command for fetching AWS credentials through Okta. 30 | 31 | ------------ 32 | Installation 33 | ------------ 34 | 35 | The recommended way to install aws-okta-processor is using `pipx`_. This has the 36 | benefit that the command is available in your shell without needing to activate 37 | a virtualenv while still keeping its dependencies isolated from site-packages:: 38 | 39 | $ pipx install aws-okta-processor 40 | 41 | and, to upgrade to a new version:: 42 | 43 | $ pipx upgrade aws-okta-processor 44 | 45 | 46 | You can also install with `pip`_ in a ``virtualenv``:: 47 | 48 | $ pip install aws-okta-processor 49 | 50 | or, if you are not installing in a ``virtualenv``, to install globally:: 51 | 52 | $ sudo pip install aws-okta-processor 53 | 54 | or for your user:: 55 | 56 | $ pip install --user aws-okta-processor 57 | 58 | 59 | If you have aws-okta-processor installed with `pip`_ and want to upgrade to the latest 60 | version you can run:: 61 | 62 | $ pip install --upgrade aws-okta-processor 63 | 64 | .. note:: 65 | 66 | On OS X, if you see an error regarding the version of six that came with 67 | distutils in El Capitan, use the ``--ignore-installed`` option:: 68 | 69 | $ sudo pip install aws-okta-processor --ignore-installed six 70 | 71 | This will install the aws-okta-processor package as well as all dependencies. You can 72 | also just `download the tarball`_. Once you have the 73 | aws-okta-processor directory structure on your workstation, you can just run:: 74 | 75 | $ cd 76 | $ python setup.py install 77 | 78 | --------------- 79 | Getting Started 80 | --------------- 81 | 82 | This package is best used in `AWS Named Profiles`_ 83 | with tools and libraries that recognize `credential_process`_. 84 | 85 | To setup aws-okta-processor in a profile create an INI formatted file like this:: 86 | 87 | [default] 88 | credential_process=aws-okta-processor authenticate --user --organization .okta.com 89 | 90 | and place it in ``~/.aws/credentials`` (or in 91 | ``%UserProfile%\.aws/credentials`` on Windows). Then run:: 92 | 93 | $ pip install awscli 94 | $ aws sts get-caller-identity 95 | 96 | Supply a password then select your AWS Okta application and account role if prompted. 97 | The AWS CLI command will return a result showing the assumed account role. If you run the 98 | AWS CLI command again you will get the same role back without any prompts due to caching. 99 | 100 | For tools and libraries that do not recognize ``credential_process`` aws-okta-processor 101 | can be ran to export the following as environment variables:: 102 | 103 | AWS_ACCESS_KEY_ID 104 | AWS_SECRET_ACCESS_KEY 105 | AWS_SESSION_TOKEN 106 | 107 | For Linux or OSX run:: 108 | 109 | $ eval $(aws-okta-processor authenticate --environment --user --organization .okta.com) 110 | 111 | On Unix systems pass a `--target-shell` in order to change the 112 | export command output. Bash is the default target shell. 113 | We also allow [fish shell](https://fishshell.com/) as a valid target:: 114 | 115 | $ eval (aws-okta-processor authenticate --environment --user --organization .okta.com --target-shell fish) 116 | 117 | For Windows run:: 118 | 119 | $ Invoke-Expression (aws-okta-processor authenticate --environment --user --organization .okta.com) 120 | 121 | ---------------------------- 122 | Other Configurable Variables 123 | ---------------------------- 124 | 125 | Additional variables can also be passed to aws-okta-processors ``authenticate`` command 126 | as options or environment variables as outlined in the table below. 127 | 128 | ============== ================ ======================= ======================================== 129 | Variable Option Environment Variable Description 130 | ============== ================ ======================= ======================================== 131 | user --user AWS_OKTA_USER Okta user name 132 | -------------- ---------------- ----------------------- ---------------------------------------- 133 | password --pass AWS_OKTA_PASS Okta user password 134 | -------------- ---------------- ----------------------- ---------------------------------------- 135 | organization --organization AWS_OKTA_ORGANIZATION Okta FQDN for Organization 136 | -------------- ---------------- ----------------------- ---------------------------------------- 137 | application --application AWS_OKTA_APPLICATION Okta AWS application URL 138 | -------------- ---------------- ----------------------- ---------------------------------------- 139 | role --role AWS_OKTA_ROLE AWS Role ARN 140 | -------------- ---------------- ----------------------- ---------------------------------------- 141 | secondary_role --secondary-role AWS_OKTA_SECONDARY_ROLE Secondary AWS Role ARN 142 | -------------- ---------------- ----------------------- ---------------------------------------- 143 | account_alias --account-alias AWS_OKTA_ACCOUNT_ALIAS AWS Account Filter 144 | -------------- ---------------- ----------------------- ---------------------------------------- 145 | region --region AWS_OKTA_REGION AWS Region 146 | -------------- ---------------- ----------------------- ---------------------------------------- 147 | duration --duration AWS_OKTA_DURATION Duration in seconds for AWS session 148 | -------------- ---------------- ----------------------- ---------------------------------------- 149 | key --key AWS_OKTA_KEY Key used in generating AWS session cache 150 | -------------- ---------------- ----------------------- ---------------------------------------- 151 | environment --environment Output command to set ENV variables 152 | -------------- ---------------- ----------------------- ---------------------------------------- 153 | silent --silent Silence Info output 154 | -------------- ---------------- ----------------------- ---------------------------------------- 155 | factor --factor AWS_OKTA_FACTOR MFA type. `push:okta`, `token:software:totp:okta`, `token:software:totp:google` and `token:hardware:yubico` are supported. 156 | -------------- ---------------- ----------------------- ---------------------------------------- 157 | no_okta_cache --no-okta-cache AWS_OKTA_NO_OKTA_CACHE Do not read okta cache 158 | -------------- ---------------- ----------------------- ---------------------------------------- 159 | no_aws_cache --no-aws-cache AWS_OKTA_NO_AWS_CACHE Do not read aws cache 160 | -------------- ---------------- ----------------------- ---------------------------------------- 161 | target_shell --target-shell AWS_OKTA_TARGET_SHELL Target shell to format export command 162 | -------------- ---------------- ----------------------- ---------------------------------------- 163 | sign_in_url --sign-in-url AWS_OKTA_SIGN_IN_URL AWS Sign In URL 164 | ============== ================ ======================= ======================================== 165 | 166 | ^^^^^^^^ 167 | Examples 168 | ^^^^^^^^ 169 | 170 | If you do not want aws-okta-processor to prompt for any selection input you can export the following:: 171 | 172 | $ export AWS_OKTA_APPLICATION= AWS_OKTA_ROLE= AWS_OKTA_FACTOR= 173 | 174 | Or pass additional options to the command:: 175 | 176 | $ aws-okta-processor authenticate --user --organization .okta.com --application --role --factor 177 | 178 | ------- 179 | Caching 180 | ------- 181 | 182 | This package leverages caching of both the Okta session and AWS sessions. It's helpful to 183 | understand how this caching works to avoid confusion when attempting to switch between AWS roles. 184 | 185 | ^^^^ 186 | Okta 187 | ^^^^ 188 | 189 | When aws-okta-processor attempts authentication it will check ``~/.aws-okta-processor/cache/`` 190 | for a file named ``--session.json`` based on the ``user`` and ``organization`` 191 | option values passed. If the file is not found or the session contents are stale then 192 | aws-okta-processor will create a new session and write it to ``~/.aws-okta-processor/cache/``. 193 | If the file exists and the session is not stale then the existing session gets refreshed. 194 | 195 | ^^^ 196 | AWS 197 | ^^^ 198 | 199 | After aws-okta-processor has a session with Okta and an AWS role has been selected it will fetch 200 | the role's keys and session token. This session information from the AWS role gets cached as a 201 | json file under ``~/.aws/boto/cache``. The file name is a SHA1 hash based on a combination the 202 | ``user``, ``organization`` and ``key`` option values passed to the command. 203 | 204 | If you want to store a seperate AWS role session cache for each role assumed using the same 205 | ``user`` and ``organization`` option values then pass a unique value to ``key``. 206 | Named profiles for different roles can then be defined in ``~/.aws/credentials`` with content like this:: 207 | 208 | [role_one] 209 | credential_process=aws-okta-processor authenticate --user --organization .okta.com --application --role --factor --key role_one 210 | 211 | [role_two] 212 | credential_process=aws-okta-processor authenticate --user --organization .okta.com --application --role --factor --key role_two 213 | 214 | To clear all AWS session caches run:: 215 | 216 | $ rm ~/.aws/boto/cache/* 217 | 218 | ------------------------- 219 | Assuming a Secondary Role 220 | ------------------------- 221 | 222 | If you can only assume a role from another role, you can assume both roles using ``--role`` and ``--secondary-role``. Use 223 | ``--role`` to specify the first role ARN, then ``--secondary-role`` to specify the role ARN assumed from ``--role``. 224 | 225 | Example:: 226 | 227 | aws-okta-processor authenticate --user jdoe ... --role arn:aws:iam::111111111:role/OpsUser --secondary-role arn:aws:iam::111111111:role/SecretsAdmin 228 | 229 | ----------------------------- 230 | Project or User Configuration 231 | ----------------------------- 232 | 233 | ``aws-okta-processor`` can inherit arguments from a ``.awsoktaprocessor`` file located in the user's home directory or the current working 234 | directory. 235 | 236 | *.awsoktaprocessor* 237 | 238 | .. code-block:: ini 239 | 240 | [defaults] 241 | user=jdoe 242 | 243 | [authenticate] 244 | user=ssmith 245 | 246 | In this example... 247 | 248 | * ``authenticate > user`` overrides ``defaults > user`` 249 | * ``{workingDir}/.awsoktaprocessor`` overrides ``~/.awsoktaprocessor`` 250 | * ``aws-okta-processor`` arguments override any options from dotfiles 251 | 252 | ----------------------------- 253 | Get Roles 254 | ----------------------------- 255 | 256 | To get roles, use the ``get-roles`` command. This command supports outputing the roles as AWS profiles, JSON, or custom formatted text. 257 | 258 | .. code-block:: bash 259 | 260 | # write all the roles as AWS profiles 261 | aws-okta-processor get-roles -u jdoe -o mycompany.okta.com --output=profiles > ~/.aws/credentials 262 | 263 | # get account and role 264 | aws-okta-processor get-roles -u jdoe -o mycompany.okta.com --output=text --output-format="{account},{role}" 265 | 266 | # get JSON 267 | aws-okta-processor get-roles -u jdoe -o mycompany.okta.com --output=json 268 | 269 | 270 | Output Types 271 | 272 | * ``json`` (default): output as JSON 273 | * ``profiles``: output AWS profiles to be stored in ``~/.aws/credentials`` 274 | * ``text``: custom formatted text using ``--output-format`` and tokens 275 | 276 | Output Format Tokens 277 | 278 | * ``{account}``: name of the account 279 | * ``{account_id}``: account Id 280 | * ``{account_raw}``: account information as seen on Okta site (``Account: blah-blah (id)``) 281 | * ``{application_url}``: full Okta application url 282 | * ``{organization}``: organization as provided 283 | * ``{role}``: role ARN 284 | * ``{role_suffix}``: last element of the role (delimited using ``AWS_OKTA_ROLE_SUFFIX_DELIMITER`` or ``-``) 285 | * ``{user}``: user as provided 286 | 287 | 288 | 289 | 290 | ------------ 291 | Getting Help 292 | ------------ 293 | 294 | * If it turns out that you may have found a bug, please `open an issue `__ 295 | 296 | --------------- 297 | Acknowledgments 298 | --------------- 299 | 300 | This package was influenced by `AlainODea `__'s 301 | work on `okta-aws-cli-assume-role `__. 302 | 303 | 304 | 305 | .. _`pip`: http://www.pip-installer.org/en/latest/ 306 | .. _`pipx`: https://pipxproject.github.io/pipx/ 307 | .. _`download the tarball`: https://pypi.org/project/aws-okta-processor/ 308 | .. _`AWS Named Profiles`: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html 309 | .. _`credential_process`: https://docs.aws.amazon.com/cli/latest/topic/config-vars.html#sourcing-credentials-from-external-processes 310 | -------------------------------------------------------------------------------- /aws_okta_processor/core/okta.py: -------------------------------------------------------------------------------- 1 | """Module for handling Okta authentication and MFA.""" 2 | 3 | import abc 4 | import os 5 | import sys 6 | import time 7 | import json 8 | import datetime 9 | 10 | from collections import OrderedDict 11 | 12 | import getpass 13 | from enum import Enum 14 | 15 | import dateutil # type: ignore[import-untyped] 16 | import requests # type: ignore[import-untyped] 17 | 18 | from six import add_metaclass # type: ignore[import-untyped] 19 | from aws_okta_processor.core import prompt 20 | from aws_okta_processor.core.tty import print_tty, input_tty 21 | 22 | 23 | OKTA_AUTH_URL = "https://{}/api/v1/authn" 24 | OKTA_SESSION_URL = "https://{}/api/v1/sessions" 25 | OKTA_REFRESH_URL = "https://{}/api/v1/sessions/me/lifecycle/refresh" 26 | OKTA_APPLICATIONS_URL = "https://{}/api/v1/users/me/appLinks" 27 | 28 | ZERO = datetime.timedelta(0) 29 | 30 | 31 | class UTC(datetime.tzinfo): 32 | """UTC Timezone class.""" 33 | 34 | def utcoffset(self, dt): 35 | return ZERO 36 | 37 | def tzname(self, dt): 38 | return "UTC" 39 | 40 | def dst(self, dt): 41 | return ZERO 42 | 43 | 44 | class Okta: # pylint: disable=R0902 45 | """Okta authentication class.""" 46 | 47 | def __init__( # pylint: disable=R0913,R0917 48 | self, 49 | user_name=None, 50 | user_pass=None, 51 | organization=None, 52 | factor=None, 53 | silent=None, 54 | no_okta_cache=None, 55 | ): 56 | """ 57 | Initialize Okta authentication with optional parameters. 58 | 59 | Attempts to use a cached session if available and valid. 60 | If not, prompts the user for necessary credentials and creates a new session. 61 | 62 | Parameters: 63 | user_name (str): The Okta username. 64 | user_pass (str): The Okta password. 65 | organization (str): The Okta organization domain. 66 | factor (str): The preferred MFA factor. 67 | silent (bool): If True, suppresses output. 68 | no_okta_cache (bool): If True, does not use cached Okta session. 69 | """ 70 | # Initialize instance variables 71 | self.user_name = user_name 72 | self.silent = silent 73 | self.factor = factor 74 | self.session = requests.Session() 75 | self.organization = organization 76 | self.okta_session_id = None 77 | self.cache_file_path = self.get_cache_file_path() 78 | 79 | okta_session = None 80 | 81 | if not no_okta_cache: 82 | # Get session from cache 83 | okta_session = self.get_okta_session_from_cache_file() 84 | 85 | if okta_session: 86 | # Refresh the session ID of the cached session 87 | self.read_aop_from_okta_session(okta_session) 88 | self.refresh_okta_session_id(okta_session=okta_session) 89 | 90 | if not self.organization: 91 | # Prompt for organization if not provided 92 | print_tty(string="Organization: ", newline=False) 93 | self.organization = input_tty() 94 | 95 | if not self.user_name: 96 | # Prompt for username if not provided 97 | print_tty(string="UserName: ", newline=False) 98 | self.user_name = input_tty() 99 | 100 | if not self.okta_session_id: 101 | # No valid session ID, proceed to authenticate 102 | if not self.user_name: 103 | print_tty(string="UserName: ", newline=False) 104 | self.user_name = input_tty() 105 | 106 | if not user_pass: 107 | # Prompt for password if not provided 108 | user_pass = getpass.getpass("Password: ") 109 | 110 | if not self.organization: 111 | print_tty(string="Organization: ", newline=False) 112 | self.organization = input_tty() 113 | 114 | # Obtain a single-use token 115 | self.okta_single_use_token = self.get_okta_single_use_token( 116 | user_name=self.user_name, user_pass=user_pass 117 | ) 118 | 119 | # This call sets self.okta_session_id 120 | self.create_and_store_okta_session() 121 | 122 | def read_aop_from_okta_session(self, okta_session): 123 | """ 124 | Reads and sets the user_name and organization from the cached Okta session. 125 | 126 | Parameters: 127 | okta_session (dict): The cached Okta session data. 128 | """ 129 | if "aws-okta-processor" in okta_session: 130 | aop_options = okta_session["aws-okta-processor"] 131 | self.user_name = aop_options.get("user_name", None) 132 | self.organization = aop_options.get("organization", None) 133 | 134 | del okta_session["aws-okta-processor"] 135 | 136 | def get_cache_file_path(self): 137 | """Returns the file path for the session cache file: 138 | ~/.aws-okta-processor/cache/--session.json 139 | """ 140 | home_directory = os.path.expanduser("~") 141 | cache_directory = os.path.join(home_directory, ".aws-okta-processor", "cache") 142 | 143 | if not os.path.isdir(cache_directory): 144 | os.makedirs(cache_directory) 145 | 146 | cache_file_name = f"{self.user_name}-{self.organization}-session.json" 147 | 148 | cache_file_path = os.path.join(cache_directory, cache_file_name) 149 | 150 | return cache_file_path 151 | 152 | def set_okta_session(self, okta_session=None): 153 | """ 154 | Saves the given Okta session in our cache file. 155 | 156 | Parameters: 157 | okta_session (dict): The Okta session data to be saved. 158 | """ 159 | session_data = dict( 160 | okta_session, 161 | **{ 162 | "aws-okta-processor": { 163 | "user_name": self.user_name, 164 | "organization": self.organization, 165 | } 166 | }, 167 | ) 168 | with open(self.cache_file_path, "w", encoding="utf-8") as file: 169 | json.dump(session_data, file) 170 | 171 | os.chmod(self.cache_file_path, 0o600) 172 | 173 | def get_okta_session_from_cache_file(self): 174 | """ 175 | Retrieves the Okta session from the cache file. 176 | 177 | Returns: 178 | dict: The cached Okta session data, or an empty dict if not found. 179 | """ 180 | session = {} 181 | 182 | if os.path.isfile(self.cache_file_path): 183 | with open(self.cache_file_path, encoding="utf-8") as file: 184 | session = json.load(file) 185 | 186 | return session 187 | 188 | def get_okta_single_use_token(self, user_name=None, user_pass=None): 189 | """ 190 | Authenticates the user and obtains a single-use Okta session token. 191 | 192 | Parameters: 193 | user_name (str): The Okta username. 194 | user_pass (str): The Okta password. 195 | 196 | Returns: 197 | str: The Okta session token. 198 | 199 | Raises: 200 | SystemExit: If authentication fails. 201 | """ 202 | headers = { 203 | "Accept": "application/json", 204 | "Content-Type": "application/json", 205 | "Cache-Control": "no-cache", 206 | } 207 | 208 | json_payload = {"username": user_name, "password": user_pass} 209 | 210 | response = self.call( 211 | endpoint=OKTA_AUTH_URL.format(self.organization), 212 | headers=headers, 213 | json_payload=json_payload, 214 | ) 215 | 216 | response_json = {} 217 | 218 | try: 219 | response_json = response.json() 220 | except ValueError: 221 | return send_error(response=response, _json=False) 222 | 223 | if "sessionToken" in response_json: 224 | return response_json["sessionToken"] 225 | 226 | if "status" in response_json: 227 | if response_json["status"] == "MFA_REQUIRED": 228 | return self.handle_factor(response_json=response_json) 229 | 230 | return send_error(response=response) 231 | 232 | def handle_factor(self, response_json=None): 233 | """ 234 | Handles multi-factor authentication (MFA) when required. 235 | 236 | Parameters: 237 | response_json (dict): The response from Okta requiring MFA. 238 | 239 | Returns: 240 | str: The Okta session token after successful MFA. 241 | 242 | Raises: 243 | SystemExit: If MFA verification fails. 244 | """ 245 | state_token = response_json["stateToken"] 246 | factors = get_supported_factors(factors=response_json["_embedded"]["factors"]) 247 | 248 | factor = prompt.get_item(items=factors, label="Factor", key=self.factor) 249 | 250 | return self.verify_factor(factor=factor, state_token=state_token) 251 | 252 | def verify_factor(self, factor=None, state_token=None): 253 | """ 254 | Verifies the selected MFA factor. 255 | 256 | Parameters: 257 | factor (FactorBase): The MFA factor object. 258 | state_token (str): The state token from Okta. 259 | 260 | Returns: 261 | str: The Okta session token after successful MFA verification. 262 | 263 | Raises: 264 | SystemExit: If MFA verification fails. 265 | """ 266 | headers = { 267 | "Accept": "application/json", 268 | "Content-Type": "application/json", 269 | "Cache-Control": "no-cache", 270 | } 271 | 272 | json_payload = factor.payload() 273 | json_payload.update({"stateToken": state_token}) 274 | 275 | response = self.call( 276 | endpoint=factor.link, headers=headers, json_payload=json_payload 277 | ) 278 | 279 | response_json = {} 280 | 281 | try: 282 | response_json = response.json() 283 | except ValueError: 284 | return send_error(response=response, _json=False) 285 | 286 | if "sessionToken" in response_json: 287 | return response_json["sessionToken"] 288 | 289 | if factor.retry(response_json): 290 | factor.link = response_json["_links"]["next"]["href"] 291 | time.sleep(1) 292 | return self.verify_factor(factor=factor, state_token=state_token) 293 | 294 | return send_error(response=response) 295 | 296 | def create_and_store_okta_session(self): 297 | """ 298 | Creates a new Okta session and caches it in our cache file for future use. 299 | 300 | https://developer.okta.com/docs/reference/api/sessions/#get-started 301 | 302 | Raises: 303 | SystemExit: If session creation fails. 304 | """ 305 | headers = {"Accept": "application/json", "Content-Type": "application/json"} 306 | 307 | json_payload = {"sessionToken": self.okta_single_use_token} 308 | 309 | response = self.call( 310 | endpoint=OKTA_SESSION_URL.format(self.organization), 311 | json_payload=json_payload, 312 | headers=headers, 313 | ) 314 | 315 | try: 316 | response_json = response.json() 317 | self.okta_session_id = response_json["id"] 318 | self.set_okta_session(okta_session=response_json) 319 | except KeyError: 320 | send_error(response=response) 321 | except ValueError: 322 | send_error(response=response, _json=False) 323 | 324 | def refresh_okta_session_id(self, okta_session=None): 325 | """ 326 | Refreshes the Okta session ID if the session is still valid. 327 | 328 | Parameters: 329 | okta_session (dict): The existing Okta session data. 330 | 331 | Raises: 332 | SystemExit: If session refresh fails. 333 | """ 334 | session_expires = dateutil.parser.parse(okta_session["expiresAt"]) 335 | 336 | if datetime.datetime.now(UTC()) < ( 337 | session_expires - datetime.timedelta(seconds=30) 338 | ): 339 | headers = { 340 | "Cookie": f"sid={okta_session['id']}", 341 | "Accept": "application/json", 342 | "Content-Type": "application/json", 343 | } 344 | 345 | response = self.call( 346 | endpoint=OKTA_REFRESH_URL.format(self.organization), 347 | headers=headers, 348 | json_payload={}, 349 | ) 350 | 351 | try: 352 | response_json = response.json() 353 | self.okta_session_id = okta_session["id"] 354 | okta_session["expiresAt"] = response_json["expiresAt"] 355 | self.set_okta_session(okta_session=okta_session) 356 | except KeyError: 357 | send_error(response=response, _exit=False) 358 | except ValueError: 359 | send_error(response=response, _json=False, _exit=False) 360 | 361 | def get_applications(self): 362 | """ 363 | Retrieves the list of Okta applications for the user. 364 | 365 | Returns: 366 | OrderedDict: A mapping of application labels to their URLs. 367 | """ 368 | applications = OrderedDict() 369 | 370 | headers = { 371 | "Cookie": f"sid={self.okta_session_id}", 372 | "Accept": "application/json", 373 | "Content-Type": "application/json", 374 | } 375 | 376 | response = self.call( 377 | endpoint=OKTA_APPLICATIONS_URL.format(self.organization), headers=headers 378 | ) 379 | 380 | for application in response.json(): 381 | if application["appName"] == "amazon_aws": 382 | label = application["label"].rstrip() 383 | link_url = application["linkUrl"] 384 | applications[label] = link_url 385 | 386 | return applications 387 | 388 | def get_saml_response(self, application_url=None): 389 | """ 390 | Retrieves the SAML response for the specified application URL. 391 | 392 | Parameters: 393 | application_url (str): The URL of the application to retrieve SAML response from. 394 | 395 | Returns: 396 | str: The SAML response content. 397 | """ # noqa: E501 398 | headers = {"Cookie": f"sid={self.okta_session_id}"} 399 | 400 | response = self.call(application_url, headers=headers) 401 | 402 | return response.content.decode() 403 | 404 | def call(self, endpoint=None, headers=None, json_payload=None): 405 | """ 406 | Makes an HTTP GET or POST request to the specified endpoint. 407 | 408 | Parameters: 409 | endpoint (str): The URL to send the request to. 410 | headers (dict): The HTTP headers to include in the request. 411 | json_payload (dict): The JSON payload for POST requests. 412 | 413 | Returns: 414 | Response: The HTTP response object. 415 | 416 | Raises: 417 | SystemExit: If a connection error or timeout occurs. 418 | """ 419 | print_tty(f"Info: Calling {endpoint}", silent=self.silent) 420 | 421 | try: 422 | if json_payload is not None: 423 | return self.session.post( 424 | endpoint, json=json_payload, headers=headers, timeout=10 425 | ) 426 | 427 | return self.session.get(endpoint, headers=headers, timeout=10) 428 | 429 | except requests.ConnectTimeout: 430 | print_tty("Error: Timed Out") 431 | sys.exit(1) 432 | 433 | except requests.ConnectionError: 434 | print_tty("Error: Connection Error") 435 | sys.exit(1) 436 | 437 | 438 | def get_supported_factors(factors=None): 439 | """ 440 | Filters and returns the supported MFA factors from the given list. 441 | 442 | Parameters: 443 | factors (list): A list of factor dictionaries from Okta. 444 | 445 | Returns: 446 | OrderedDict: A mapping of factor keys to FactorBase instances. 447 | """ 448 | matching_factors = OrderedDict() 449 | 450 | for factor in factors: 451 | try: 452 | supported_factor = FactorBase.factory(factor["factorType"]) 453 | 454 | key = f"{factor['factorType']}:{factor['provider']}".lower() 455 | matching_factors[key] = supported_factor( 456 | link=factor["_links"]["verify"]["href"] 457 | ) 458 | except NotImplementedError: 459 | pass 460 | 461 | return matching_factors 462 | 463 | 464 | def send_error(response=None, _json=True, _exit=True): 465 | """ 466 | Handles and prints error messages from HTTP responses. 467 | 468 | Parameters: 469 | response (Response): The HTTP response object. 470 | _json (bool): Whether to parse and display JSON error details. 471 | _exit (bool): Whether to exit the program after printing the error. 472 | 473 | Returns: 474 | None 475 | """ 476 | print_tty(f"Error: Status Code: {response.status_code}") 477 | 478 | if _json: 479 | response_json = response.json() 480 | 481 | if "status" in response_json: 482 | print_tty(f"Error: Status: {response_json['status']}") 483 | 484 | if "errorSummary" in response_json: 485 | print_tty(f"Error: Summary: {response_json['errorSummary']}") 486 | else: 487 | print_tty("Error: Invalid JSON") 488 | 489 | if _exit: 490 | sys.exit(1) 491 | 492 | 493 | class FactorType(str, Enum): # pylint: disable=R0903 494 | """Factor types supported by Okta.""" 495 | 496 | PUSH = "push" 497 | TOTP = "token:software:totp" 498 | HARDWARE = "token:hardware" 499 | 500 | 501 | @add_metaclass(abc.ABCMeta) 502 | class FactorBase: 503 | """ 504 | Abstract base class for different MFA factor types. 505 | """ 506 | 507 | factor: FactorType 508 | 509 | def __init__(self, link=None): 510 | """ 511 | Initializes the factor with a verification link. 512 | 513 | Parameters: 514 | link (str): The verification link for the factor. 515 | """ 516 | self.link = link 517 | 518 | @classmethod 519 | def factory(cls, factor): 520 | """ 521 | Factory method to create a factor instance based on the factor type. 522 | 523 | Parameters: 524 | factor (str): The factor type. 525 | 526 | Returns: 527 | FactorBase: An instance of a subclass of FactorBase. 528 | 529 | Raises: 530 | NotImplementedError: If the factor type is not implemented. 531 | """ 532 | for impl in cls.__subclasses__(): 533 | if factor == impl.factor: 534 | return impl 535 | raise NotImplementedError(f"Factor type not implemented: {factor}") 536 | 537 | @staticmethod 538 | @abc.abstractmethod 539 | def payload(): 540 | """ 541 | Returns a dictionary with the payload to verify the factor type. 542 | 543 | Must be implemented by subclasses. 544 | """ 545 | 546 | @abc.abstractmethod 547 | def retry(self, response): 548 | """ 549 | Determines whether the factor verification should be retried based on the response. 550 | 551 | Parameters: 552 | response (dict): The response from the factor verification attempt. 553 | 554 | Returns: 555 | bool: True if the verification should be retried, False otherwise. 556 | 557 | Must be implemented by subclasses. 558 | """ # noqa: E501 559 | 560 | 561 | class FactorPush(FactorBase): 562 | """ 563 | Handles Okta Verify Push MFA factor. 564 | """ 565 | 566 | factor = FactorType.PUSH 567 | 568 | def __init__(self, link=None): 569 | super().__init__(link=link) 570 | self.RETRYABLE_RESULTS = ["WAITING"] # pylint: disable=C0103 571 | 572 | @staticmethod 573 | def payload(): 574 | """ 575 | Returns an empty payload for push verification. 576 | """ 577 | return {} 578 | 579 | def retry(self, response): 580 | """ 581 | Checks if the push verification should be retried. 582 | 583 | Parameters: 584 | response (dict): The response from the factor verification attempt. 585 | 586 | Returns: 587 | bool: True if the factor result is 'WAITING', False otherwise. 588 | """ 589 | return response.get("factorResult") in self.RETRYABLE_RESULTS 590 | 591 | 592 | class FactorTOTP(FactorBase): 593 | """ 594 | Handles TOTP MFA factors like Google Authenticator. 595 | """ 596 | 597 | factor = FactorType.TOTP 598 | 599 | def __init__(self, link=None): 600 | super().__init__(link=link) 601 | 602 | @staticmethod 603 | def payload(): 604 | """ 605 | Prompts the user for a TOTP passcode. 606 | 607 | Returns: 608 | dict: The payload containing the passcode. 609 | """ 610 | return {"passCode": getpass.getpass("Token: ")} 611 | 612 | def retry(self, response): 613 | """ 614 | TOTP verification does not support retries. 615 | 616 | Parameters: 617 | response (dict): The response from the factor verification attempt. 618 | 619 | Returns: 620 | bool: False always. 621 | """ 622 | return False 623 | 624 | 625 | class FactorHardwareToken(FactorBase): 626 | """ 627 | Handles hardware token MFA factors. 628 | """ 629 | 630 | factor = FactorType.HARDWARE 631 | 632 | def __init__(self, link=None): 633 | super().__init__(link=link) 634 | 635 | @staticmethod 636 | def payload(): 637 | """ 638 | Prompts the user for a hardware token passcode. 639 | 640 | Returns: 641 | dict: The payload containing the passcode. 642 | """ 643 | return {"passCode": getpass.getpass("Hardware Token: ")} 644 | 645 | def retry(self, response): 646 | """ 647 | Hardware token verification does not support retries. 648 | 649 | Parameters: 650 | response (dict): The response from the factor verification attempt. 651 | 652 | Returns: 653 | bool: False always. 654 | """ 655 | return False 656 | -------------------------------------------------------------------------------- /tests/core/test_okta.py: -------------------------------------------------------------------------------- 1 | from tests.test_base import TestBase 2 | from tests.test_base import SESSION_RESPONSE 3 | from tests.test_base import AUTH_TOKEN_RESPONSE 4 | from tests.test_base import AUTH_MFA_PUSH_RESPONSE 5 | from tests.test_base import AUTH_MFA_TOTP_RESPONSE 6 | from tests.test_base import AUTH_MFA_MULTIPLE_RESPONSE 7 | from tests.test_base import AUTH_MFA_YUBICO_HARDWARE_RESPONSE 8 | from tests.test_base import MFA_WAITING_RESPONSE 9 | from tests.test_base import APPLICATIONS_RESPONSE 10 | from tests.test_base import SAML_RESPONSE 11 | 12 | from mock import patch 13 | from mock import call 14 | from mock import MagicMock 15 | from mock import mock_open 16 | from datetime import datetime 17 | from collections import OrderedDict 18 | from requests import ConnectionError 19 | from requests import ConnectTimeout 20 | 21 | from aws_okta_processor.core.okta import Okta 22 | 23 | import responses 24 | import json 25 | 26 | 27 | class StubDate(datetime): 28 | @classmethod 29 | def now(cls, tz = None): 30 | return datetime(1, 1, 1, 0, 0, tzinfo=tz) 31 | 32 | 33 | class TestOkta(TestBase): 34 | @patch('aws_okta_processor.core.okta.os.chmod') 35 | @patch('aws_okta_processor.core.okta.open') 36 | @patch('aws_okta_processor.core.okta.os.makedirs') 37 | @patch('aws_okta_processor.core.okta.print_tty') 38 | @responses.activate 39 | def test_okta( 40 | self, 41 | mock_print_tty, 42 | mock_makedirs, 43 | mock_open, 44 | mock_chmod 45 | ): 46 | responses.add( 47 | responses.POST, 48 | 'https://organization.okta.com/api/v1/authn', 49 | json=json.loads(AUTH_TOKEN_RESPONSE) 50 | ) 51 | 52 | responses.add( 53 | responses.POST, 54 | 'https://organization.okta.com/api/v1/sessions', 55 | json=json.loads(SESSION_RESPONSE) 56 | ) 57 | 58 | okta = Okta( 59 | user_name="user_name", 60 | user_pass="user_pass", 61 | organization="organization.okta.com" 62 | ) 63 | 64 | self.assertEqual(okta.okta_single_use_token, "single_use_token") 65 | self.assertEqual(okta.organization, "organization.okta.com") 66 | self.assertEqual(okta.okta_session_id, "session_token") 67 | 68 | @patch('aws_okta_processor.core.okta.os.chmod') 69 | @patch('aws_okta_processor.core.okta.open') 70 | @patch('aws_okta_processor.core.okta.getpass') 71 | @patch('aws_okta_processor.core.okta.os.makedirs') 72 | @patch('aws_okta_processor.core.okta.print_tty') 73 | @responses.activate 74 | def test_okta_no_pass( 75 | self, 76 | mock_print_tty, 77 | mock_makedirs, 78 | mock_getpass, 79 | mock_open, 80 | mock_chmod 81 | ): 82 | mock_getpass.getpass.return_value = "user_pass" 83 | 84 | responses.add( 85 | responses.POST, 86 | 'https://organization.okta.com/api/v1/authn', 87 | json=json.loads(AUTH_TOKEN_RESPONSE) 88 | ) 89 | 90 | responses.add( 91 | responses.POST, 92 | 'https://organization.okta.com/api/v1/sessions', 93 | json=json.loads(SESSION_RESPONSE) 94 | ) 95 | 96 | okta = Okta( 97 | user_name="user_name", 98 | organization="organization.okta.com" 99 | ) 100 | 101 | mock_getpass.getpass.assert_called_once() 102 | self.assertEqual(okta.okta_single_use_token, "single_use_token") 103 | self.assertEqual(okta.organization, "organization.okta.com") 104 | self.assertEqual(okta.okta_session_id, "session_token") 105 | 106 | @patch('aws_okta_processor.core.okta.Okta.read_aop_from_okta_session') 107 | @patch('aws_okta_processor.core.okta.os.chmod') 108 | @patch('aws_okta_processor.core.okta.open') 109 | @patch('aws_okta_processor.core.okta.datetime.datetime', StubDate) 110 | @patch('aws_okta_processor.core.okta.os.path.isfile') 111 | @patch('aws_okta_processor.core.okta.os.makedirs') 112 | @patch('aws_okta_processor.core.okta.print_tty') 113 | @responses.activate 114 | def test_okta_cached_session( 115 | self, 116 | mock_print_tty, 117 | mock_makedirs, 118 | mock_isfile, 119 | mock_open, 120 | mock_chmod, 121 | mock_read_aop_session 122 | ): 123 | mock_isfile.return_value = True 124 | mock_enter = MagicMock() 125 | mock_enter.read.return_value = SESSION_RESPONSE 126 | mock_open().__enter__.return_value = mock_enter 127 | 128 | session_refresh = json.loads(SESSION_RESPONSE) 129 | 130 | responses.add( 131 | responses.POST, 132 | 'https://organization.okta.com/api/v1/sessions/me/lifecycle/refresh', 133 | json=session_refresh 134 | ) 135 | 136 | okta = Okta( 137 | user_name="user_name", 138 | user_pass="user_pass", 139 | organization="organization.okta.com" 140 | ) 141 | 142 | self.assertEqual(okta.okta_session_id, "session_token") 143 | self.assertEqual(okta.organization, "organization.okta.com") 144 | 145 | mock_read_aop_session.assert_called_once_with(session_refresh) 146 | 147 | @patch('aws_okta_processor.core.okta.os.makedirs') 148 | @patch('aws_okta_processor.core.okta.print_tty') 149 | @responses.activate 150 | def test_okta_auth_value_error( 151 | self, 152 | mock_print_tty, 153 | mock_makedirs 154 | ): 155 | responses.add( 156 | responses.POST, 157 | 'https://organization.okta.com/api/v1/authn', 158 | body="NOT JSON", 159 | status=500 160 | ) 161 | 162 | with self.assertRaises(SystemExit): 163 | Okta( 164 | user_name="user_name", 165 | user_pass="user_pass", 166 | organization="organization.okta.com" 167 | ) 168 | 169 | print_tty_calls = [ 170 | call("Error: Status Code: 500"), 171 | call("Error: Invalid JSON") 172 | ] 173 | 174 | mock_print_tty.assert_has_calls(print_tty_calls) 175 | 176 | @patch('aws_okta_processor.core.okta.os.makedirs') 177 | @patch('aws_okta_processor.core.okta.print_tty') 178 | @responses.activate 179 | def test_okta_auth_send_error( 180 | self, 181 | mock_print_tty, 182 | mock_makedirs 183 | ): 184 | responses.add( 185 | responses.POST, 186 | 'https://organization.okta.com/api/v1/authn', 187 | json={ 188 | "status": "foo", 189 | "errorSummary": "bar" 190 | }, 191 | status=500 192 | ) 193 | 194 | with self.assertRaises(SystemExit): 195 | Okta( 196 | user_name="user_name", 197 | user_pass="user_pass", 198 | organization="organization.okta.com" 199 | ) 200 | 201 | print_tty_calls = [ 202 | call("Error: Status Code: 500"), 203 | call("Error: Status: foo"), 204 | call("Error: Summary: bar") 205 | ] 206 | 207 | mock_print_tty.assert_has_calls(print_tty_calls) 208 | 209 | @patch('aws_okta_processor.core.okta.os.chmod') 210 | @patch('aws_okta_processor.core.okta.open') 211 | @patch('aws_okta_processor.core.okta.os.makedirs') 212 | @patch('aws_okta_processor.core.okta.print_tty') 213 | @responses.activate 214 | def test_okta_mfa_push_challenge( 215 | self, 216 | mock_print_tty, 217 | mock_makedirs, 218 | mock_open, 219 | mock_chmod 220 | ): 221 | responses.add( 222 | responses.POST, 223 | 'https://organization.okta.com/api/v1/authn', 224 | json=json.loads(AUTH_MFA_PUSH_RESPONSE) 225 | ) 226 | 227 | responses.add( 228 | responses.POST, 229 | 'https://organization.okta.com/api/v1/authn/factors/id/verify', 230 | json=json.loads(MFA_WAITING_RESPONSE) 231 | ) 232 | 233 | responses.add( 234 | responses.POST, 235 | 'https://organization.okta.com/api/v1/authn/factors/id/lifecycle/activate/poll', 236 | json=json.loads(AUTH_TOKEN_RESPONSE) 237 | ) 238 | 239 | responses.add( 240 | responses.POST, 241 | 'https://organization.okta.com/api/v1/sessions', 242 | json=json.loads(SESSION_RESPONSE) 243 | ) 244 | 245 | okta = Okta( 246 | user_name="user_name", 247 | user_pass="user_pass", 248 | organization="organization.okta.com" 249 | ) 250 | 251 | self.assertEqual(okta.okta_single_use_token, "single_use_token") 252 | self.assertEqual(okta.organization, "organization.okta.com") 253 | self.assertEqual(okta.okta_session_id, "session_token") 254 | 255 | @patch('aws_okta_processor.core.okta.getpass.getpass') 256 | @patch('aws_okta_processor.core.okta.os.chmod') 257 | @patch('aws_okta_processor.core.okta.open') 258 | @patch('aws_okta_processor.core.okta.os.makedirs') 259 | @patch('aws_okta_processor.core.okta.print_tty') 260 | @responses.activate 261 | def test_okta_mfa_totp_challenge( 262 | self, 263 | mock_print_tty, 264 | mock_makedirs, 265 | mock_open, 266 | mock_chmod, 267 | mock_get_pass 268 | ): 269 | mock_get_pass.return_value = "123456" 270 | 271 | responses.add( 272 | responses.POST, 273 | 'https://organization.okta.com/api/v1/authn', 274 | json=json.loads(AUTH_MFA_TOTP_RESPONSE) 275 | ) 276 | 277 | responses.add( 278 | responses.POST, 279 | 'https://organization.okta.com/api/v1/authn/factors/id/verify', 280 | json=json.loads(AUTH_TOKEN_RESPONSE) 281 | ) 282 | 283 | responses.add( 284 | responses.POST, 285 | 'https://organization.okta.com/api/v1/sessions', 286 | json=json.loads(SESSION_RESPONSE) 287 | ) 288 | 289 | okta = Okta( 290 | user_name="user_name", 291 | user_pass="user_pass", 292 | organization="organization.okta.com" 293 | ) 294 | 295 | self.assertEqual(okta.okta_single_use_token, "single_use_token") 296 | self.assertEqual(okta.organization, "organization.okta.com") 297 | self.assertEqual(okta.okta_session_id, "session_token") 298 | 299 | @patch('aws_okta_processor.core.okta.Okta.get_okta_single_use_token') 300 | @patch('aws_okta_processor.core.okta.Okta.create_and_store_okta_session') 301 | @patch('aws_okta_processor.core.okta.input_tty') 302 | def test_read_aop_from_okta_session_should_read_aop_options( 303 | self, 304 | mock_input, 305 | mock_get_session_id, 306 | mock_get_token 307 | ): 308 | okta = Okta( 309 | user_name="user_name", 310 | user_pass="user_pass", 311 | organization="organization.okta.com", 312 | no_okta_cache=False 313 | ) 314 | okta.read_aop_from_okta_session({ 315 | "aws-okta-processor": { 316 | "user_name": "user2_name", 317 | "organization": "organization2.okta.com" 318 | } 319 | }) 320 | 321 | self.assertEqual(okta.user_name, "user2_name") 322 | self.assertEqual(okta.organization, "organization2.okta.com") 323 | 324 | 325 | @patch('aws_okta_processor.core.okta.os.chmod') 326 | @patch('aws_okta_processor.core.okta.Okta.get_cache_file_path', return_value='/tmp/test.json') 327 | @patch('aws_okta_processor.core.okta.Okta.get_okta_single_use_token') 328 | @patch('aws_okta_processor.core.okta.Okta.create_and_store_okta_session') 329 | @patch('aws_okta_processor.core.okta.input_tty') 330 | @patch('builtins.open', new_callable=mock_open) 331 | def test_set_okta_session_should_write_session_data( 332 | self, 333 | mock_open_file, 334 | mock_input, 335 | mock_get_session_id, 336 | mock_get_token, 337 | mock_get_cache_file, 338 | mock_chmod 339 | ): 340 | okta = Okta( 341 | user_name="user_name", 342 | user_pass="user_pass", 343 | organization="organization.okta.com", 344 | no_okta_cache=False 345 | ) 346 | okta.set_okta_session({ 347 | "session_stuff": "yes" 348 | }) 349 | 350 | mock_open_file.assert_called_once_with('/tmp/test.json', 'w', encoding="utf-8") 351 | mock_open_file().write.assert_has_calls([ 352 | call('{'), 353 | call('"session_stuff"'), 354 | call(': '), 355 | call('"yes"'), 356 | call(', '), 357 | call('"aws-okta-processor"'), 358 | call(': '), 359 | call('{'), 360 | call('"user_name"'), 361 | call(': '), 362 | call('"user_name"'), 363 | call(', '), 364 | call('"organization"'), 365 | call(': '), 366 | call('"organization.okta.com"'), 367 | call('}'), 368 | call('}') 369 | ]) 370 | 371 | @patch('aws_okta_processor.core.okta.getpass.getpass') 372 | @patch('aws_okta_processor.core.okta.os.chmod') 373 | @patch('aws_okta_processor.core.okta.open') 374 | @patch('aws_okta_processor.core.okta.os.makedirs') 375 | @patch('aws_okta_processor.core.okta.print_tty') 376 | @responses.activate 377 | def test_okta_mfa_hardware_token_challenge( 378 | self, 379 | mock_print_tty, 380 | mock_makedirs, 381 | mock_open, 382 | mock_chmod, 383 | mock_getpass 384 | ): 385 | mock_getpass.return_value = "123456" 386 | 387 | responses.add( 388 | responses.POST, 389 | 'https://organization.okta.com/api/v1/authn', 390 | json=json.loads(AUTH_MFA_YUBICO_HARDWARE_RESPONSE) 391 | ) 392 | 393 | responses.add( 394 | responses.POST, 395 | 'https://organization.okta.com/api/v1/authn/factors/id/verify', 396 | json=json.loads(AUTH_TOKEN_RESPONSE) 397 | ) 398 | 399 | responses.add( 400 | responses.POST, 401 | 'https://organization.okta.com/api/v1/sessions', 402 | json=json.loads(SESSION_RESPONSE) 403 | ) 404 | 405 | okta = Okta( 406 | user_name="user_name", 407 | user_pass="user_pass", 408 | organization="organization.okta.com" 409 | ) 410 | 411 | self.assertEqual(okta.okta_single_use_token, "single_use_token") 412 | self.assertEqual(okta.organization, "organization.okta.com") 413 | self.assertEqual(okta.okta_session_id, "session_token") 414 | 415 | @patch('aws_okta_processor.core.prompt.input_tty') 416 | @patch('aws_okta_processor.core.okta.os.chmod') 417 | @patch('aws_okta_processor.core.okta.open') 418 | @patch('aws_okta_processor.core.okta.os.makedirs') 419 | @patch('aws_okta_processor.core.okta.print_tty', new=MagicMock()) 420 | @patch('aws_okta_processor.core.prompt.print_tty', new=MagicMock()) 421 | @responses.activate 422 | def test_okta_mfa_push_multiple_factor_challenge( 423 | self, 424 | mock_makedirs, 425 | mock_open, 426 | mock_chmod, 427 | mock_input 428 | ): 429 | mock_input.return_value = "2" 430 | 431 | responses.add( 432 | responses.POST, 433 | 'https://organization.okta.com/api/v1/authn', 434 | json=json.loads(AUTH_MFA_MULTIPLE_RESPONSE) 435 | ) 436 | 437 | responses.add( 438 | responses.POST, 439 | 'https://organization.okta.com/api/v1/authn/factors/id/verify', 440 | json=json.loads(MFA_WAITING_RESPONSE) 441 | ) 442 | 443 | responses.add( 444 | responses.POST, 445 | 'https://organization.okta.com/api/v1/authn/factors/id/lifecycle/activate/poll', 446 | json=json.loads(AUTH_TOKEN_RESPONSE) 447 | ) 448 | 449 | responses.add( 450 | responses.POST, 451 | 'https://organization.okta.com/api/v1/sessions', 452 | json=json.loads(SESSION_RESPONSE) 453 | ) 454 | 455 | okta = Okta( 456 | user_name="user_name", 457 | user_pass="user_pass", 458 | organization="organization.okta.com" 459 | ) 460 | 461 | self.assertEqual(okta.okta_single_use_token, "single_use_token") 462 | self.assertEqual(okta.organization, "organization.okta.com") 463 | self.assertEqual(okta.okta_session_id, "session_token") 464 | 465 | @patch('aws_okta_processor.core.okta.os.chmod') 466 | @patch('aws_okta_processor.core.okta.open') 467 | @patch('aws_okta_processor.core.okta.os.makedirs') 468 | @patch('aws_okta_processor.core.okta.print_tty') 469 | @responses.activate 470 | def test_okta_mfa_verify_value_error( 471 | self, 472 | mock_print_tty, 473 | mock_makedirs, 474 | mock_open, 475 | mock_chmod 476 | ): 477 | responses.add( 478 | responses.POST, 479 | 'https://organization.okta.com/api/v1/authn', 480 | json=json.loads(AUTH_MFA_PUSH_RESPONSE) 481 | ) 482 | 483 | responses.add( 484 | responses.POST, 485 | 'https://organization.okta.com/api/v1/authn/factors/id/verify', 486 | body="NOT JSON", 487 | status=500 488 | ) 489 | 490 | with self.assertRaises(SystemExit): 491 | Okta( 492 | user_name="user_name", 493 | user_pass="user_pass", 494 | organization="organization.okta.com" 495 | ) 496 | 497 | print_tty_calls = [ 498 | call("Error: Status Code: 500"), 499 | call("Error: Invalid JSON") 500 | ] 501 | 502 | mock_print_tty.assert_has_calls(print_tty_calls) 503 | 504 | @patch('aws_okta_processor.core.okta.os.chmod') 505 | @patch('aws_okta_processor.core.okta.open') 506 | @patch('aws_okta_processor.core.okta.os.makedirs') 507 | @patch('aws_okta_processor.core.okta.print_tty') 508 | @responses.activate 509 | def test_okta_mfa_verify_send_error( 510 | self, 511 | mock_print_tty, 512 | mock_makedirs, 513 | mock_open, 514 | mock_chmod 515 | ): 516 | responses.add( 517 | responses.POST, 518 | 'https://organization.okta.com/api/v1/authn', 519 | json=json.loads(AUTH_MFA_PUSH_RESPONSE) 520 | ) 521 | 522 | responses.add( 523 | responses.POST, 524 | 'https://organization.okta.com/api/v1/authn/factors/id/verify', 525 | json={ 526 | "status": "foo", 527 | "errorSummary": "bar" 528 | }, 529 | status=500 530 | ) 531 | 532 | with self.assertRaises(SystemExit): 533 | Okta( 534 | user_name="user_name", 535 | user_pass="user_pass", 536 | organization="organization.okta.com" 537 | ) 538 | 539 | print_tty_calls = [ 540 | call("Error: Status Code: 500"), 541 | call("Error: Status: foo"), 542 | call("Error: Summary: bar") 543 | ] 544 | 545 | mock_print_tty.assert_has_calls(print_tty_calls) 546 | 547 | @patch('aws_okta_processor.core.okta.os.chmod') 548 | @patch('aws_okta_processor.core.okta.open') 549 | @patch('aws_okta_processor.core.okta.os.makedirs') 550 | @patch('aws_okta_processor.core.okta.print_tty') 551 | @responses.activate 552 | def test_okta_session_id_key_error( 553 | self, 554 | mock_print_tty, 555 | mock_makedirs, 556 | mock_open, 557 | mock_chmod 558 | ): 559 | responses.add( 560 | responses.POST, 561 | 'https://organization.okta.com/api/v1/authn', 562 | json=json.loads(AUTH_TOKEN_RESPONSE) 563 | ) 564 | 565 | responses.add( 566 | responses.POST, 567 | 'https://organization.okta.com/api/v1/sessions', 568 | json={ 569 | "status": "foo", 570 | "errorSummary": "bar" 571 | }, 572 | status=500 573 | ) 574 | 575 | with self.assertRaises(SystemExit): 576 | Okta( 577 | user_name="user_name", 578 | user_pass="user_pass", 579 | organization="organization.okta.com" 580 | ) 581 | 582 | print_tty_calls = [ 583 | call("Error: Status Code: 500"), 584 | call("Error: Status: foo"), 585 | call("Error: Summary: bar") 586 | ] 587 | 588 | mock_print_tty.assert_has_calls(print_tty_calls) 589 | 590 | @patch('aws_okta_processor.core.okta.os.chmod') 591 | @patch('aws_okta_processor.core.okta.open') 592 | @patch('aws_okta_processor.core.okta.os.makedirs') 593 | @patch('aws_okta_processor.core.okta.print_tty') 594 | @responses.activate 595 | def test_okta_session_id_value_error( 596 | self, 597 | mock_print_tty, 598 | mock_makedirs, 599 | mock_open, 600 | mock_chmod 601 | ): 602 | responses.add( 603 | responses.POST, 604 | 'https://organization.okta.com/api/v1/authn', 605 | json=json.loads(AUTH_TOKEN_RESPONSE) 606 | ) 607 | 608 | responses.add( 609 | responses.POST, 610 | 'https://organization.okta.com/api/v1/sessions', 611 | body="NOT JSON", 612 | status=500 613 | ) 614 | 615 | with self.assertRaises(SystemExit): 616 | Okta( 617 | user_name="user_name", 618 | user_pass="user_pass", 619 | organization="organization.okta.com" 620 | ) 621 | 622 | print_tty_calls = [ 623 | call("Error: Status Code: 500"), 624 | call("Error: Invalid JSON") 625 | ] 626 | 627 | mock_print_tty.assert_has_calls(print_tty_calls) 628 | 629 | @patch('aws_okta_processor.core.okta.os.chmod') 630 | @patch('aws_okta_processor.core.okta.open') 631 | @patch('aws_okta_processor.core.okta.datetime.datetime', StubDate) 632 | @patch('aws_okta_processor.core.okta.os.path.isfile') 633 | @patch('aws_okta_processor.core.okta.os.makedirs') 634 | @patch('aws_okta_processor.core.okta.print_tty') 635 | @responses.activate 636 | def test_okta_refresh_key_error( 637 | self, 638 | mock_print_tty, 639 | mock_makedirs, 640 | mock_isfile, 641 | mock_open, 642 | mock_chmod 643 | ): 644 | mock_isfile.return_value = True 645 | mock_enter = MagicMock() 646 | mock_enter.read.return_value = SESSION_RESPONSE 647 | mock_open().__enter__.return_value = mock_enter 648 | 649 | responses.add( 650 | responses.POST, 651 | 'https://organization.okta.com/api/v1/sessions/me/lifecycle/refresh', 652 | json={ 653 | "status": "foo", 654 | "errorSummary": "bar" 655 | }, 656 | status=500 657 | ) 658 | 659 | Okta( 660 | user_name="user_name", 661 | user_pass="user_pass", 662 | organization="organization.okta.com" 663 | ) 664 | 665 | print_tty_calls = [ 666 | call("Error: Status Code: 500"), 667 | call("Error: Status: foo"), 668 | call("Error: Summary: bar") 669 | ] 670 | 671 | mock_print_tty.assert_has_calls(print_tty_calls) 672 | 673 | @patch('aws_okta_processor.core.okta.os.chmod') 674 | @patch('aws_okta_processor.core.okta.open') 675 | @patch('aws_okta_processor.core.okta.datetime.datetime', StubDate) 676 | @patch('aws_okta_processor.core.okta.os.path.isfile') 677 | @patch('aws_okta_processor.core.okta.os.makedirs') 678 | @patch('aws_okta_processor.core.okta.print_tty') 679 | @responses.activate 680 | def test_okta_refresh_value_error( 681 | self, 682 | mock_print_tty, 683 | mock_makedirs, 684 | mock_isfile, 685 | mock_open, 686 | mock_chmod 687 | ): 688 | mock_isfile.return_value = True 689 | mock_enter = MagicMock() 690 | mock_enter.read.return_value = SESSION_RESPONSE 691 | mock_open().__enter__.return_value = mock_enter 692 | 693 | responses.add( 694 | responses.POST, 695 | 'https://organization.okta.com/api/v1/sessions/me/lifecycle/refresh', 696 | body="bob", 697 | status=500 698 | ) 699 | 700 | with self.assertRaises(SystemExit): 701 | Okta( 702 | user_name="user_name", 703 | user_pass="user_pass", 704 | organization="organization.okta.com" 705 | ) 706 | 707 | print_tty_calls = [ 708 | call("Error: Status Code: 500"), 709 | call("Error: Invalid JSON") 710 | ] 711 | 712 | mock_print_tty.assert_has_calls(print_tty_calls) 713 | 714 | @patch('aws_okta_processor.core.okta.os.chmod') 715 | @patch('aws_okta_processor.core.okta.open') 716 | @patch('aws_okta_processor.core.okta.os.makedirs') 717 | @patch('aws_okta_processor.core.okta.print_tty') 718 | @responses.activate 719 | def test_okta_get_applications( 720 | self, 721 | mock_print_tty, 722 | mock_makedirs, 723 | mock_open, 724 | mock_chmod 725 | ): 726 | responses.add( 727 | responses.POST, 728 | 'https://organization.okta.com/api/v1/authn', 729 | json=json.loads(AUTH_TOKEN_RESPONSE) 730 | ) 731 | 732 | responses.add( 733 | responses.POST, 734 | 'https://organization.okta.com/api/v1/sessions', 735 | json=json.loads(SESSION_RESPONSE) 736 | ) 737 | 738 | responses.add( 739 | responses.GET, 740 | 'https://organization.okta.com/api/v1/users/me/appLinks', 741 | json=json.loads(APPLICATIONS_RESPONSE) 742 | ) 743 | 744 | okta = Okta( 745 | user_name="user_name", 746 | user_pass="user_pass", 747 | organization="organization.okta.com" 748 | ) 749 | 750 | applications = okta.get_applications() 751 | expected_applications = OrderedDict( 752 | [ 753 | ('AWS', 'https://organization.okta.com/home/amazon_aws/0oa3omz2i9XRNSRIHBZO/270'), 754 | ('AWS GOV', 'https://organization.okta.com/home/amazon_aws/0oa3omz2i9XRNSRIHBZO/272') 755 | ] 756 | ) 757 | 758 | self.assertEqual(applications, expected_applications) 759 | 760 | @patch('aws_okta_processor.core.okta.os.chmod') 761 | @patch('aws_okta_processor.core.okta.open') 762 | @patch('aws_okta_processor.core.okta.os.makedirs') 763 | @patch('aws_okta_processor.core.okta.print_tty') 764 | @responses.activate 765 | def test_okta_get_saml_response( 766 | self, 767 | mock_print_tty, 768 | mock_makedirs, 769 | mock_open, 770 | mock_chmod 771 | ): 772 | responses.add( 773 | responses.POST, 774 | 'https://organization.okta.com/api/v1/authn', 775 | json=json.loads(AUTH_TOKEN_RESPONSE) 776 | ) 777 | 778 | responses.add( 779 | responses.POST, 780 | 'https://organization.okta.com/api/v1/sessions', 781 | json=json.loads(SESSION_RESPONSE) 782 | ) 783 | 784 | responses.add( 785 | responses.GET, 786 | 'https://organization.okta.com/home/amazon_aws/0oa3omz2i9XRNSRIHBZO/270', 787 | body=SAML_RESPONSE 788 | ) 789 | 790 | okta = Okta( 791 | user_name="user_name", 792 | user_pass="user_pass", 793 | organization="organization.okta.com" 794 | ) 795 | 796 | saml_response = okta.get_saml_response( 797 | application_url='https://organization.okta.com/home/amazon_aws/0oa3omz2i9XRNSRIHBZO/270' 798 | ) 799 | 800 | self.assertEqual(saml_response, SAML_RESPONSE) 801 | 802 | @patch('aws_okta_processor.core.okta.os.chmod') 803 | @patch('aws_okta_processor.core.okta.open') 804 | @patch('aws_okta_processor.core.okta.os.makedirs') 805 | @patch('aws_okta_processor.core.okta.print_tty') 806 | @responses.activate 807 | def test_okta_connection_timeout( 808 | self, 809 | mock_print_tty, 810 | mock_makedirs, 811 | mock_open, 812 | mock_chmod 813 | ): 814 | responses.add( 815 | responses.POST, 816 | 'https://organization.okta.com/api/v1/authn', 817 | body=ConnectTimeout() 818 | ) 819 | 820 | with self.assertRaises(SystemExit): 821 | Okta( 822 | user_name="user_name", 823 | user_pass="user_pass", 824 | organization="organization.okta.com" 825 | ) 826 | 827 | print_tty_calls = [ 828 | call("Error: Timed Out") 829 | ] 830 | 831 | mock_print_tty.assert_has_calls(print_tty_calls) 832 | 833 | @patch('aws_okta_processor.core.okta.os.chmod') 834 | @patch('aws_okta_processor.core.okta.open') 835 | @patch('aws_okta_processor.core.okta.os.makedirs') 836 | @patch('aws_okta_processor.core.okta.print_tty') 837 | @responses.activate 838 | def test_okta_connection_error( 839 | self, 840 | mock_print_tty, 841 | mock_makedirs, 842 | mock_open, 843 | mock_chmod 844 | ): 845 | responses.add( 846 | responses.POST, 847 | 'https://organization.okta.com/api/v1/authn', 848 | body=ConnectionError() 849 | ) 850 | 851 | with self.assertRaises(SystemExit): 852 | Okta( 853 | user_name="user_name", 854 | user_pass="user_pass", 855 | organization="organization.okta.com" 856 | ) 857 | 858 | print_tty_calls = [ 859 | call("Error: Connection Error") 860 | ] 861 | 862 | mock_print_tty.assert_has_calls(print_tty_calls) 863 | --------------------------------------------------------------------------------